From 0bb99f9348b73bd1e67cfafe334fde524263d83f Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:17:26 +0200 Subject: [PATCH 1/9] Add dev utils and format everything --- .JuliaFormatter.toml | 19 +++++++ .gitignore | 6 +++ Makefile | 77 +++++++++++++++++++++++++++++ README.md | 1 + dev/Project.toml | 6 +++ dev/scripts/bumpversion.jl | 50 +++++++++++++++++++ dev/scripts/pre-commit-hook.jl | 43 ++++++++++++++++ src/Client.jl | 57 +++++++++++---------- src/Common.jl | 3 +- src/Server.jl | 90 ++++++++++++++++++++++------------ test/client.jl | 34 +++++-------- test/runtests.jl | 3 +- test/server.jl | 14 ++---- 13 files changed, 310 insertions(+), 93 deletions(-) create mode 100644 .JuliaFormatter.toml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 dev/Project.toml create mode 100755 dev/scripts/bumpversion.jl create mode 100755 dev/scripts/pre-commit-hook.jl diff --git a/.JuliaFormatter.toml b/.JuliaFormatter.toml new file mode 100644 index 0000000..9338253 --- /dev/null +++ b/.JuliaFormatter.toml @@ -0,0 +1,19 @@ +indent = 4 +margin = 80 +always_for_in = true +whitespace_typedefs = true +whitespace_ops_in_indices = false +remove_extra_newlines = true +short_to_long_function_def = true +long_to_short_function_def = false +always_use_return = true +annotate_untyped_fields_with_any = true +conditional_to_if = true +normalize_line_endings = "unix" +trailing_comma = true +indent_submodule = true +separate_kwargs_with_semicolon = true +format_markdown = true + +#? This breaks src/engee_front.jl docstrings. If true use `ignore` option. +# format_docstrings = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cef0d64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +.git-credentials +.vscode +Makefile.local + +Manifest.toml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..073f474 --- /dev/null +++ b/Makefile @@ -0,0 +1,77 @@ +HELP_DESCRIPTION = "Various utilities for the current project." +JULIA_BIN ?= julia +JULIA_ARGS ?= --threads=6,2 --optimize=0 +CLEAN_DIRS ?= tmp/ test/tmp/ +JULIA_TEST_FAILFAST ?= true +JULIA_TEST_VERBOSE ?= true + +.PHONY: all +all: help + +-include Makefile.local + +.PHONY: repl +repl: # Start Julia REPL in the current project + JULIA_PKG_PRECOMPILE_AUTO=0 $(JULIA_BIN) $(JULIA_ARGS) --project=. + +.PHONY: test +test: # Run tests + @echo "Testing..." + JULIA_TEST_FAILFAST=$(JULIA_TEST_FAILFAST) \ + JULIA_TEST_VERBOSE=$(JULIA_TEST_VERBOSE) \ + $(JULIA_BIN) $(JULIA_ARGS) --compile=min --startup-file=no --project=. \ + -e 'import Pkg; Pkg.test()' + +.PHONY: clean +clean: # Clean project artifacts + $(foreach path,$(CLEAN_DIRS), \ + rm -rf $(path); \ + ) + +.PHONY: format +format: # Format all Julia files in the current directory + $(JULIA_BIN) $(JULIA_ARGS) --project=dev -e 'using JuliaFormatter; format(".")' + +.PHONY: pre-commit-install +pre-commit-install: # Install pre-commit hooks + cp dev/scripts/pre-commit-hook.jl .git/hooks/pre-commit + +.PHONY: pre-commit-uninstall +pre-commit-uninstall: # Uninstall pre-commit hooks + rm .git/hooks/pre-commit + +.PHONY: bump-major +bump-major: # Increment project MAJOR version + $(JULIA_BIN) $(JULIA_ARGS) dev/scripts/bumpversion.jl Project.toml --major + +.PHONY: bump-minor +bump-minor: # Increment project MINOR version + $(JULIA_BIN) $(JULIA_ARGS) dev/scripts/bumpversion.jl Project.toml --minor + +.PHONY: bump-patch +bump-patch: # Increment project PATCH version + $(JULIA_BIN) $(JULIA_ARGS) dev/scripts/bumpversion.jl Project.toml --patch + +.PHONY: bump-prerelease +bump-prerelease: # Increment project PRERELEASE version + $(JULIA_BIN) $(JULIA_ARGS) dev/scripts/bumpversion.jl Project.toml --prerelease + +.PHONY: init-dev +init-dev: # Initialize dev environment + $(JULIA_BIN) $(JULIA_ARGS) --project=dev -e 'import Pkg; Pkg.instantiate()' + +.PHONY: reset-dev +reset-dev: # Reset dev environment + $(JULIA_BIN) $(JULIA_ARGS) --project=dev \ + -e 'rm("dev/Manifest.toml"; force=true); import Pkg; Pkg.instantiate()' + +.PHONY: help +help: +ifdef HELP_DESCRIPTION + @printf "%s\n\n" $(HELP_DESCRIPTION) | fmt +endif + @echo "Available commands:" + @grep -vE '^[[:space:]]' $(MAKEFILE_LIST) | \ + grep -E '^.*:.* #' | \ + sed -E 's/(.*):(.*):.*#(.*)/ \2###\3/' | \ + column -t -s '###' diff --git a/README.md b/README.md index ba1890d..52f124e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,3 @@ +# SimpleHTTP.jl A draft of a package to easily make and serve HTTP requests, without defining boilerplate types diff --git a/dev/Project.toml b/dev/Project.toml new file mode 100644 index 0000000..5056b10 --- /dev/null +++ b/dev/Project.toml @@ -0,0 +1,6 @@ +[deps] +JuliaFormatter = "98e50ef6-434e-11e9-1051-2b60c6c9e899" + +[compat] +JuliaFormatter = "1.0.62" +julia = "1.11" diff --git a/dev/scripts/bumpversion.jl b/dev/scripts/bumpversion.jl new file mode 100755 index 0000000..4d893a0 --- /dev/null +++ b/dev/scripts/bumpversion.jl @@ -0,0 +1,50 @@ +#!/usr/bin/env -S julia --project=dev + +import Pkg + +function main(args) + @assert length(args) == 2 "pass `project_file` and patch `type`" + project_file = args[1] + type = args[2] + + project = Pkg.Types.read_project("Project.toml") + old_version = project.version + new_version = if type == "--major" + Base.nextmajor(project.version) + elseif type == "--minor" + Base.nextminor(project.version) + elseif type == "--patch" + Base.nextpatch(project.version) + elseif type == "--prerelease" + nextprerelease(project.version) + else + throw(ArgumentError("type `$type` is not supported")) + end + project.version = new_version + Pkg.Types.write_project(project, project_file) + println( + "Bumped project version in $project_file from v$old_version to v$new_version", + ) + + return 0 +end + +function thisprerelease(v::VersionNumber) + return VersionNumber(v.major, v.minor, v.patch, v.prerelease) +end + +function nextprerelease(v::VersionNumber) + return if v < thisprerelease(v) + thisprerelease(v) + else + @assert length(v.prerelease) == 2 "only prerelease versions like `rc.1` are supported" + VersionNumber( + v.major, + v.minor, + v.patch, + (v.prerelease[1], v.prerelease[2] + 1), + ) + end +end + +(abspath(PROGRAM_FILE) == @__FILE__) && exit(main(ARGS)) diff --git a/dev/scripts/pre-commit-hook.jl b/dev/scripts/pre-commit-hook.jl new file mode 100755 index 0000000..b519d8a --- /dev/null +++ b/dev/scripts/pre-commit-hook.jl @@ -0,0 +1,43 @@ +#!/usr/bin/env -S julia --project=dev + +import JuliaFormatter: format_file +import Pkg + +function main(_) + @info "This is git pre-commit hook. Looking for jl files to format..." + @debug "Using julia project: $(Pkg.project().path)" + + this_file = @__FILE__ + source_file = "scripts/pre-commit-hook.jl" + @debug "pre-commit debug info" this_file source_file + diff = + `diff $this_file $source_file --color=always` |> + ignorestatus |> + readchomp + if diff != "" + @error "Installed pre-commit hook is stale, run `make pre-commit-install`" + println(stderr, "\nShowing diff...") + println(stderr, diff) + return 1 + end + + staged = + split(readchomp(`git diff --cached --name-only`), "\n") |> + filter(isfile) + unstaged = split(readchomp(`git diff --name-only`), "\n") + if !isempty(unstaged) && unstaged[1] != "" + @warn "You have unstaged files!" + end + + format_diff = .!format_file.(staged; verbose = false) + if any(format_diff) + formatted = staged[format_diff] + @error "Formatted $(sum(format_diff)) jl files:\n$(join(formatted, "\n"))" + println("\nAborting commit...") + return 1 + end + @info "All is fine, commiting..." + return 0 +end + +(abspath(PROGRAM_FILE) == @__FILE__) && exit(main(ARGS)) diff --git a/src/Client.jl b/src/Client.jl index 2deadd3..e44abd6 100644 --- a/src/Client.jl +++ b/src/Client.jl @@ -1,9 +1,23 @@ module Client -using ..Common: make_response, report_error, ParamData, read_json, - parse_params, write_json, ArgLoc, ErrorResponse, deserialize, - JSONFIELD, QUERY, URL, JSONFIELD, JSON, ALLHEADERS, HEADER +using ..Common: + make_response, + report_error, + ParamData, + read_json, + parse_params, + write_json, + ArgLoc, + ErrorResponse, + deserialize, + JSONFIELD, + QUERY, + URL, + JSONFIELD, + JSON, + ALLHEADERS, + HEADER import OrderedCollections: OrderedDict import MacroTools @@ -69,10 +83,7 @@ end function get_exception(resp, err_map) if !haskey(err_map, resp.status) - throw(UnexpectedResponseError( - resp.status, - get_error(resp) - )) + throw(UnexpectedResponseError(resp.status, get_error(resp))) end type = err_map[resp.status] return deserialize(resp.body, type) @@ -82,7 +93,9 @@ function get_headers_def(params) headers = filter(((_, par),) -> par.loc == HEADER, params) all_headers = filter(((_, par),) -> par.loc == ALLHEADERS, params) if !isempty(headers) && !isempty(all_headers) - error("Cannot have individual headers and generel Headers in one signature") + error( + "Cannot have individual headers and generel Headers in one signature", + ) end if isempty(headers) && isempty(all_headers) return :headers, :(headers = Dict{String, String}()) @@ -92,9 +105,7 @@ function get_headers_def(params) return :headers, :(headers = $var_name) end pairs = (:($(par.headerKey) => $var_name) for (var_name, par) in headers) - return :headers, :(headers = Dict{String, String}( - $(pairs...) - )) + return :headers, :(headers = Dict{String, String}($(pairs...))) end function construct_expressions(cfg, path, method, sig, err_map) @@ -127,9 +138,7 @@ function construct_expressions(cfg, path, method, sig, err_map) elseif !isempty(body_params) body_type, body_def = construct_body_type(body_params, route_name) create_body_expr = quote - req_body = $write_json($body_type( - $(keys(body_params)...) - )) + req_body = $write_json($body_type($(keys(body_params)...))) end elseif !isempty(full_body_param) length(full_body_param) == 1 || @@ -137,9 +146,7 @@ function construct_expressions(cfg, path, method, sig, err_map) body_type = last(only(full_body_param)).type body_def = nothing create_body_expr = quote - req_body = $write_json($body_type( - $(only(keys(full_body_param))) - )) + req_body = $write_json($body_type($(only(keys(full_body_param))))) end else body_def = nothing @@ -147,14 +154,14 @@ function construct_expressions(cfg, path, method, sig, err_map) create_body_expr = nothing end - - func_args = Iterators.map(params) do (argname, par) - if isnothing(par.default) - return :($(argname)::$(par.type)) - else - return Expr(:kw, :($argname::$(par.type)), par.default) - end - end |> collect + func_args = + Iterators.map(params) do (argname, par) + if isnothing(par.default) + return :($(argname)::$(par.type)) + else + return Expr(:kw, :($argname::$(par.type)), par.default) + end + end |> collect #! format: off query_args = ( :($(string(name))=>string($name)) for name in keys(query_params)) url_patterm = if !isempty(url_params) diff --git a/src/Common.jl b/src/Common.jl index e52c64b..9ee4c70 100644 --- a/src/Common.jl +++ b/src/Common.jl @@ -49,8 +49,7 @@ end function error_string(e::T) where {T <: Exception} flds = fieldnames(T) - if length(flds) == 1 && - only(fieldtypes(T)) <: AbstractString + if length(flds) == 1 && only(fieldtypes(T)) <: AbstractString return getproperty(e, only(flds)) end return string(e) diff --git a/src/Server.jl b/src/Server.jl index 280db42..9f4a612 100644 --- a/src/Server.jl +++ b/src/Server.jl @@ -1,10 +1,22 @@ - module Server -using ..Common: make_response, report_error, ParamData, read_json, - parse_params, write_json, ArgLoc, serialize, - JSONFIELD, URL, QUERY, JSON, ALLHEADERS, HEADER, ErrorResponse +using ..Common: + make_response, + report_error, + ParamData, + read_json, + parse_params, + write_json, + ArgLoc, + serialize, + JSONFIELD, + URL, + QUERY, + JSON, + ALLHEADERS, + HEADER, + ErrorResponse import OrderedCollections: OrderedDict import MacroTools @@ -43,9 +55,7 @@ function error_response(errors_map, e::Exception, verbosity_500::Int) else err = "Internal server error" end - return make_response(500, - serialize(ErrorResponse(err)) - ) + return make_response(500, serialize(ErrorResponse(err))) end return make_response(code, serialize(e)) end @@ -61,7 +71,9 @@ function no_param_provided_response(param_name::String, is_header::Bool) return make_response( 422, write_json( - ErrorResponse("Required $(is_header ? "header" : "parameter") \"$param_name\" not provided"), + ErrorResponse( + "Required $(is_header ? "header" : "parameter") \"$param_name\" not provided", + ), ), ) end @@ -88,9 +100,7 @@ function construct_body_type( end)) end -function normalize_headers( - hdrs -) +function normalize_headers(hdrs) return ((lowercase(hdr) => value) for (hdr, value) in hdrs) end @@ -118,11 +128,14 @@ function construct_handler( for (argname, param) in params argname_str = string(argname) if param.loc == JSONFIELD - push!(arg_defs, :($argname = if !isnothing(parsedbody.$(argname)) - parsedbody.$(argname) - else - $(param.default) - end)) + push!( + arg_defs, + :($argname = if !isnothing(parsedbody.$(argname)) + parsedbody.$(argname) + else + $(param.default) + end), + ) continue elseif param.loc == JSON push!(arg_defs, :($argname = parsedbody)) @@ -142,14 +155,20 @@ function construct_handler( arg_defs, :( !$haskey($param_source, $param_key) && - return $no_param_provided_response($param_key, $is_header) + return $no_param_provided_response( + $param_key, + $is_header, + ) ), ) end if param.type == :String || param.type == :AbstractString push!( arg_defs, - :($argname = $get($param_source, $param_key, $(param.default))), + :( + $argname = + $get($param_source, $param_key, $(param.default)) + ), ) continue end @@ -166,17 +185,16 @@ function construct_handler( else $(param.default) end - ) + ), ) continue elseif param.loc == ALLHEADERS if !isnothing(param.default) - error("Having default for all headers for a server method is currently unsupported") + error( + "Having default for all headers for a server method is currently unsupported", + ) end - push!( - arg_defs, - :($argname = req_headers) - ) + push!(arg_defs, :($argname = req_headers)) continue else error("Unknown parameter location $(param.loc)") @@ -192,14 +210,19 @@ function construct_handler( $get_query_params(req), something($HTTP.getparams(req), Dict{String, String}()), ) - req_headers = Dict{String, String}($normalize_headers(req.headers)...) + req_headers = + Dict{String, String}($normalize_headers(req.headers)...) $parsing $(arg_defs...) res = try $route_function($(argnames...)) catch e $report_error(e) - return $error_response($errors_map, e, ($cfg_expr).verbosity_500) + return $error_response( + $errors_map, + e, + ($cfg_expr).verbosity_500, + ) end return $make_response($resp_code, $serialize(res)) end @@ -255,8 +278,14 @@ function create_route_bodies(path, func, cfg, errors) $functionbody end)) #! format: on - handler_name, handler = - construct_handler(params, body_type, rettype, route_name, errors_var, cfg) + handler_name, handler = construct_handler( + params, + body_type, + rettype, + route_name, + errors_var, + cfg, + ) return errors_def, handler_name, body_def, handler_func, handler end @@ -278,12 +307,11 @@ function create_route(cfg, path::String, method::String, handler::Expr, errors) end function serve(cfg::ServerConfig) - HTTP.serve(cfg.router, cfg.port) + return HTTP.serve(cfg.router, cfg.port) end - function serve!(cfg::ServerConfig) - HTTP.serve!(cfg.router, cfg.port) + return HTTP.serve!(cfg.router, cfg.port) end @doc raw""" diff --git a/test/client.jl b/test/client.jl index c930f9d..117cc6f 100644 --- a/test/client.jl +++ b/test/client.jl @@ -5,13 +5,9 @@ using ..ServerTest: User, UserNotFoundError using SimpleHTTP using UUIDs: uuid4, UUID -const cfg = ClientConfig( - url = "http://0.0.0.0:8080/api/v1/test", -) +const cfg = ClientConfig(; url = "http://0.0.0.0:8080/api/v1/test") -const exceptions = Dict{Int, Type{<:Exception}}( - 404 => UserNotFoundError -) +const exceptions = Dict{Int, Type{<:Exception}}(404 => UserNotFoundError) Client.@post( cfg, @@ -34,19 +30,9 @@ Client.@post( exceptions ) -Client.@get( - cfg, - "/users/get/{id}", - get_user(id::UUID)::User, - exceptions -) +Client.@get(cfg, "/users/get/{id}", get_user(id::UUID)::User, exceptions) -Client.@get( - cfg, - "/users/get", - get_all_users()::Dict{UUID, User}, - exceptions -) +Client.@get(cfg, "/users/get", get_all_users()::Dict{UUID, User}, exceptions) Client.@get( cfg, @@ -65,7 +51,7 @@ Client.@get( Client.@get( cfg, "/users/echo_lang", - client_default_lang_ru(lang::Headers["Accept-Language"] = "ru")::String, + client_default_lang_ru(; lang::Headers["Accept-Language"] = "ru")::String, exceptions ) @@ -79,10 +65,12 @@ Client.@get( Client.@get( cfg, "/users/echo_headers", - echo_headers_with_default(hdrs::Headers = Dict{String, String}( - "accept-language" => "ru", - "auth" => "secret_token" - ))::Dict{String, String}, + echo_headers_with_default(; + hdrs::Headers = Dict{String, String}( + "accept-language" => "ru", + "auth" => "secret_token", + ), + )::Dict{String, String}, exceptions ) diff --git a/test/runtests.jl b/test/runtests.jl index 0fd5f52..d2792f2 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -2,7 +2,6 @@ using SimpleHTTP using Test - include("server.jl") include("client.jl") @@ -55,7 +54,7 @@ end default_headers = Dict{String, String}( "accept-language" => "ru", - "auth" => "secret_token" + "auth" => "secret_token", ) res = App.echo_headers_with_default() diff --git a/test/server.jl b/test/server.jl index fa7fb27..a131eb4 100644 --- a/test/server.jl +++ b/test/server.jl @@ -8,16 +8,14 @@ struct UserNotFoundError <: Exception end using UUIDs: uuid4, UUID -const cfg = ServerConfig( +const cfg = ServerConfig(; ip = ip"0.0.0.0", port = 8080, path = "/api/v1/test", verbosity_500 = 2, ) -const error_codes = Pair{DataType, Int}[ - UserNotFoundError => 404, -] +const error_codes = Pair{DataType, Int}[UserNotFoundError=>404,] mutable struct User id::UUID @@ -43,11 +41,7 @@ Server.@post( "/users/create", function create_user(data::Json{CreateUserRequest})::UUID id = uuid4() - users[id] = User( - id, - data.age, - data.name, - ) + users[id] = User(id, data.age, data.name) return id end, error_codes @@ -111,4 +105,4 @@ Server.@get( error_codes ) -end \ No newline at end of file +end From 7ff9623b2328bf088a2a5b1d3f785e54a33ae10e Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:28:52 +0200 Subject: [PATCH 2/9] Add CLAUDE.md --- CLAUDE.md | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9cc66b0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Style + +### Git + +- Use conventional commit namings +- Write comprehensive, yet succint commit summaries that support commit name and diff, without utterly describing it + +## Commands + +```bash +make test # Run tests (failfast=true, verbose=true by default) +make format # Format all Julia files with JuliaFormatter +make repl # Start Julia REPL with project loaded +make clean # Remove tmp/ and test/tmp/ +make init-dev # Initialize dev environment (needed for format) +make pre-commit-install # Install git pre-commit hooks +``` + +Test environment variables (override via env or `Makefile.local`): + +- `JULIA_TEST_FAILFAST` — default `true` +- `JULIA_TEST_VERBOSE` — default `true` + +Formatting is configured in `.JuliaFormatter.toml` (4-space indent, 80-char margin, trailing commas). + +## Architecture + +The package provides macro-driven HTTP server and client abstractions, eliminating boilerplate type definitions via metaprogramming. + +**Module structure:** + +- `SimpleHTTP` (entry: [src/SimpleHTTP.jl](src/SimpleHTTP.jl)) — re-exports `Server`, `Client`, `HTTP`, `ServerConfig`, `ClientConfig`, `@ip_str`, `IPAddr` +- `Common` ([src/Common.jl](src/Common.jl)) — shared utilities: `ParamData`, `ArgLoc` enum, JSON serialization (`write_json`/`read_json`), `parse_params`, `ErrorResponse` +- `Server` ([src/Server.jl](src/Server.jl)) — HTTP server submodule +- `Client` ([src/Client.jl](src/Client.jl)) — HTTP client submodule + +**Core design — macro-driven route definitions:** + +Both `Server` and `Client` expose `@get`, `@post`, `@put`, `@delete` macros that take a config, path, and function signature. At expansion time, they: + +1. Parse the function signature's type annotations to determine how each argument maps to the request (`parse_params` in `Common`) +2. Generate a handler (server) or request function (client) with all parameter extraction/serialization wired in + +**Parameter type annotation conventions** (declared in function signatures): + +- Plain type (e.g., `::Int`) → query parameter (or URL param if `{argname}` appears in path) +- `Json{T}` → full request/response body deserialized as `T` +- `JsonField{T}` → individual field from a JSON body +- `Headers` → all request headers as `Dict{String,String}` +- `Headers["key"]` → specific header by name (lowercased) +- Default values (`= val`) make parameters optional + +**Server macro signature:** + +```julia +Server.@get(cfg, "/path/{id}", function route_name(id::String, q::Int = 0)::RetType + # body +end, error_codes_dict) +``` + +**Client macro signature:** + +```julia +Client.@get(cfg, "/path/{id}", route_name(id::String, q::Int = 0)::RetType, error_codes_dict) +``` + +**Error handling:** + +- Server: `error_codes` is a `Dict{Type,Int}` mapping exception types → HTTP status codes. Unmapped exceptions → 500 (body controlled by `verbosity_500`). Mapped exceptions are serialized via `Common.serialize`. +- Client: `err_map` is a `Dict{Int,Type}` mapping HTTP status codes → exception types. Unmapped non-2xx → `Client.UnexpectedResponseError`. Matched statuses → `deserialize(resp.body, type)`. + +**Response codes:** + +- `200` — successful response with body +- `204` — handler returns `Nothing` +- `422` — parameter/body parsing failure + +**Serialization:** JSON3 with `allow_inf=true`. Error responses use `ErrorResponse` struct with a single `error::String` field. + +**Tests** ([test/](test/)) use a live server (`Server.serve!`) with a `ServerTest` module defining actual routes and a `ClientTest` module defining the matching client calls, then exercise them end-to-end. From e725107c295faa4d553c1b01198e539330b9fd12 Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:49:37 +0200 Subject: [PATCH 3/9] fix: handle keyword args in `parse_params` and correct `delete!` call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parse_params` crashed on `Expr(:parameters, ...)` — the AST node Julia emits for semicolon-separated keyword args in a call expression. Routes using `f(; key::Headers["X"] = "default")` now unpack correctly into `ParamData`, preserving defaults. Also corrected `delete_user` passing a one-element `Vector` instead of the `UUID` to `delete!`, which silently left users in the store after deletion. Co-Authored-By: Claude Sonnet 4.6 --- src/Common.jl | 25 +++++++++++++++++++++++++ test/server.jl | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/Common.jl b/src/Common.jl index 9ee4c70..7fab690 100644 --- a/src/Common.jl +++ b/src/Common.jl @@ -96,6 +96,31 @@ function parse_params(args, path, route_name) params = OrderedDict{Symbol, ParamData}() for arg_expr in args + # Handle keyword arguments block (semicolon syntax: f(; kw::T = default)) + if isa(arg_expr, Expr) && arg_expr.head == :parameters + for kw_expr in arg_expr.args + argdefault = nothing + if isa(kw_expr, Expr) && kw_expr.head == :kw + typed_arg = kw_expr.args[1] + argdefault = kw_expr.args[2] + MacroTools.@capture(typed_arg, argname_Symbol::argtype_) || + error( + "route $route_name: invalid keyword argument $kw_expr", + ) + else + MacroTools.@capture(kw_expr, argname_Symbol::argtype_) || + error( + "route $route_name: invalid keyword argument $kw_expr", + ) + end + haskey(params, argname) && + error("route $route_name: duplicate argument $argname") + params[argname] = + get_param_data(argname, argtype, path, argdefault) + end + continue + end + argdefault = nothing MacroTools.@capture(arg_expr, argname_Symbol::argtype_ = argdefault_) || MacroTools.@capture(arg_expr, argname_Symbol::argtype_) || diff --git a/test/server.jl b/test/server.jl index a131eb4..3c8bf75 100644 --- a/test/server.jl +++ b/test/server.jl @@ -51,7 +51,7 @@ Server.@delete( cfg, "/users/delete/{id}", function delete_user(id::UUID)::Nothing - delete!(users, [id]) + delete!(users, id) return nothing end, error_codes From cbfa7572739707d7c52544e8ce1039cac28e6482 Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:50:16 +0200 Subject: [PATCH 4/9] test: add JSON array response tests for `Vector{String}` and `Vector{Int}` Routes `GET /users/names` and `GET /users/ages` return server-sorted arrays, exercising full round-trip serialization of typed arrays through `JSON3`. Tests cover non-empty results, element ordering, and empty-array baseline after deletion. Co-Authored-By: Claude Sonnet 4.6 --- test/client.jl | 4 ++++ test/runtests.jl | 21 +++++++++++++++++++++ test/server.jl | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/test/client.jl b/test/client.jl index 117cc6f..0ca1e5c 100644 --- a/test/client.jl +++ b/test/client.jl @@ -74,4 +74,8 @@ Client.@get( exceptions ) +Client.@get(cfg, "/users/names", get_user_names()::Vector{String}, exceptions) + +Client.@get(cfg, "/users/ages", get_user_ages()::Vector{Int}, exceptions) + end diff --git a/test/runtests.jl b/test/runtests.jl index d2792f2..7b731aa 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -63,3 +63,24 @@ end res = App.echo_headers_with_default(Dict{String, String}()) @test all(!haskey(res, k) for k in keys(default_headers)) end + +@testset "json array responses" begin + id1 = App.create_user("Alice", 30) + id2 = App.create_user("Bob", 25) + id3 = App.create_user("Carol", 35) + + names = App.get_user_names() + @test names isa Vector{String} + @test names == ["Alice", "Bob", "Carol"] + + ages = App.get_user_ages() + @test ages isa Vector{Int} + @test ages == [25, 30, 35] + + App.delete_user(id1) + App.delete_user(id2) + App.delete_user(id3) + + @test App.get_user_names() == String[] + @test App.get_user_ages() == Int[] +end diff --git a/test/server.jl b/test/server.jl index 3c8bf75..a40fa91 100644 --- a/test/server.jl +++ b/test/server.jl @@ -105,4 +105,22 @@ Server.@get( error_codes ) +Server.@get( + cfg, + "/users/names", + function get_user_names()::Vector{String} + return sort([u.name for u in values(users)]) + end, + error_codes +) + +Server.@get( + cfg, + "/users/ages", + function get_user_ages()::Vector{Int} + return sort([u.age for u in values(users)]) + end, + error_codes +) + end From 9e8164da2e5c89d2ea1e2e8869e3608c39e6ee71 Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:54:19 +0200 Subject: [PATCH 5/9] ci: add GitHub Actions for tests, coverage, and Dependabot Tests run on Ubuntu, macOS, and Windows with the current Julia release. Coverage is collected separately on Ubuntu via `julia-actions/julia-runtest` with `coverage: true`, processed into `lcov.info`, and uploaded to Codecov. `workflow_dispatch` allows manual triggering from the GitHub Actions UI. Dependabot is configured to keep GitHub Actions and Julia dependencies up to date on a weekly schedule. Co-Authored-By: Claude Sonnet 4.6 --- .github/dependabot.yml | 14 ++++++++++ .github/workflows/CI.yml | 59 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/CI.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5141596 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "julia" + directory: "/" + schedule: + interval: "weekly" + # groups: # uncomment to group all julia package updates into a single PR + # all-julia-packages: + # patterns: + # - "*" diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..1b13c84 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,59 @@ +name: CI + +on: + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Julia ${{ matrix.version }} - ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: + actions: write + contents: read + strategy: + fail-fast: false + matrix: + version: + - '1' + os: + - ubuntu-latest + - macos-latest + - windows-latest + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 + with: + version: ${{ matrix.version }} + - uses: julia-actions/cache@v2 + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-runtest@v1 + + coverage: + name: Coverage + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + actions: write + contents: read + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 + with: + version: '1' + - uses: julia-actions/cache@v2 + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-runtest@v1 + with: + coverage: true + - uses: julia-actions/julia-processcoverage@v1 + - uses: codecov/codecov-action@v4 + with: + files: lcov.info + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false From b35977e81d387fa867da87cb0aa2aaac9dfd7667 Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:13:51 +0200 Subject: [PATCH 6/9] Add push trigger for all branches in CI workflow --- .github/workflows/CI.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 1b13c84..234d23a 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -1,6 +1,9 @@ name: CI on: + push: + branches: + - '**' # Run on all branches pull_request: workflow_dispatch: From 0d46fb9a57833a206a28302e1ff1c341ba8fadaf Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:25:03 +0200 Subject: [PATCH 7/9] ci: update codecov version --- .github/workflows/CI.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 234d23a..c02095d 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -55,7 +55,7 @@ jobs: with: coverage: true - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v4 + - uses: codecov/codecov-action@v5 with: files: lcov.info token: ${{ secrets.CODECOV_TOKEN }} From e732e0e0531bab4b0c2605b2b3da53b963f981ee Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:34:17 +0200 Subject: [PATCH 8/9] Update Project compat --- Project.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index 5538669..0910ccc 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "SimpleHTTP" uuid = "03cab53d-42c8-4ab5-8d7c-14ec844b19aa" -authors = ["Denis Revunov "] version = "0.1.0" +authors = ["Denis Revunov "] [deps] HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" @@ -16,9 +16,10 @@ UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" [compat] HTTP = "1.10.17" JSON3 = "1.14.3" -MacroTools = "0.5.16" +MacroTools = "0.5.16 - 0" Match = "2.4.0" OrderedCollections = "1.8.1" Sockets = "1.11.0" Test = "1.11.0" UUIDs = "1.11.0" +julia = "1.12" From b7593bca4785ee1fafc7e09bdcbdff6c408adf88 Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:36:56 +0200 Subject: [PATCH 9/9] ci: run only on changes to `src`, `test`, or `Project.toml` Avoids triggering expensive matrix runs for documentation, config, or tooling-only commits. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/CI.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index c02095d..2f2fe06 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -3,8 +3,16 @@ name: CI on: push: branches: - - '**' # Run on all branches + - '**' + paths: + - 'src/**' + - 'test/**' + - 'Project.toml' pull_request: + paths: + - 'src/**' + - 'test/**' + - 'Project.toml' workflow_dispatch: concurrency: