From 5e97faf1435558bbf77b4891a1d8d2420ce2c04d Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:05:38 -0700 Subject: [PATCH 1/9] Start 0.3.0 development --- CHANGES.md | 4 ++++ dune-project | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 3e6e929..02dc1f2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ # Changes +## 0.3.0 - Unreleased + +- Start 0.3.0 development. + ## 0.2.0 - 2026-06-09 - Add pure response validation for declared status codes and JSON response diff --git a/dune-project b/dune-project index d452804..f01268c 100644 --- a/dune-project +++ b/dune-project @@ -1,7 +1,7 @@ (lang dune 3.11) (name contract) -(version 0.2.0) +(version 0.3.0) (package (name contract) From 28aaa5939fcc323819422e207e2a56fa5c461ee1 Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:20:40 -0700 Subject: [PATCH 2/9] Add pure API route matching --- lib/api.ml | 147 ++++++++++++++++++++++++++++++++++++++++++ lib/api.mli | 22 +++++++ lib/contract.ml | 1 + lib/contract.mli | 1 + test/dune | 1 + test/test_api.ml | 117 +++++++++++++++++++++++++++++++++ test/test_contract.ml | 1 + 7 files changed, 290 insertions(+) create mode 100644 lib/api.ml create mode 100644 lib/api.mli create mode 100644 test/test_api.ml diff --git a/lib/api.ml b/lib/api.ml new file mode 100644 index 0000000..3d542a6 --- /dev/null +++ b/lib/api.ml @@ -0,0 +1,147 @@ +type t = { title : string; version : string; endpoints : Endpoint.t list } + +let title api = api.title +let version api = api.version +let endpoints api = api.endpoints + +let endpoint_label endpoint = + Endpoint.method_to_string endpoint.Endpoint.method_ + ^ " " + ^ Path_template.raw endpoint.path + +let route_shape endpoint = + endpoint.Endpoint.path |> Path_template.segments + |> List.map (function + | Path_template.Static segment -> "static:" ^ segment + | Param _ -> "param") + +let same_method left right = left.Endpoint.method_ = right.Endpoint.method_ + +let duplicate_route left right = + same_method left right + && String.equal + (Path_template.raw left.Endpoint.path) + (Path_template.raw right.Endpoint.path) + +let ambiguous_route left right = + same_method left right + && (not + (String.equal + (Path_template.raw left.Endpoint.path) + (Path_template.raw right.Endpoint.path))) + && route_shape left = route_shape right + +let duplicate_error endpoint = + Error.make ~location:Error.Route ~got:(endpoint_label endpoint) + "duplicate endpoint declaration" + +let ambiguous_error left right = + Error.make ~location:Error.Route + ~got:(endpoint_label left ^ " conflicts with " ^ endpoint_label right) + "ambiguous endpoint route" + +let validate_routes endpoints = + let rec validate seen = function + | [] -> Ok () + | endpoint :: rest -> ( + match List.find_opt (duplicate_route endpoint) seen with + | Some _ -> Error (duplicate_error endpoint) + | None -> ( + match List.find_opt (ambiguous_route endpoint) seen with + | Some conflicting -> Error (ambiguous_error conflicting endpoint) + | None -> validate (endpoint :: seen) rest)) + in + validate [] endpoints + +let make ~title ~version endpoints = + match validate_routes endpoints with + | Ok () -> Ok { title; version; endpoints } + | Error error -> Error error + +let static_count endpoint = + endpoint.Endpoint.path |> Path_template.segments + |> List.fold_left + (fun count -> function + | Path_template.Static _ -> count + 1 | Param _ -> count) + 0 + +let plain_route_mismatch error = + error.Error.location = Error.Route + && String.equal error.message "path does not match route" + +let first_relevant_error errors = + List.find_opt (fun error -> not (plain_route_mismatch error)) errors + +let matching_endpoints endpoints method_ path = + let rec collect matches errors = function + | [] -> (List.rev matches, List.rev errors) + | endpoint :: rest when endpoint.Endpoint.method_ <> method_ -> + collect matches errors rest + | endpoint :: rest -> ( + match Path_template.match_path endpoint.path path with + | Ok _ -> collect (endpoint :: matches) errors rest + | Error error -> collect matches (error :: errors) rest) + in + collect [] [] endpoints + +let best_match = function + | [] -> None + | first :: rest -> + let rec choose best best_score tied = function + | [] -> Some (best, tied) + | endpoint :: rest -> + let score = static_count endpoint in + if score > best_score then choose endpoint score [] rest + else if score = best_score then + choose best best_score (endpoint :: tied) rest + else choose best best_score tied rest + in + choose first (static_count first) [] rest + +let ambiguous_match_error endpoint tied = + let labels = endpoint :: tied |> List.map endpoint_label in + Error.make ~location:Error.Route + ~got:(String.concat " conflicts with " labels) + "ambiguous endpoint match" + +let unique_methods endpoints = + let add methods endpoint = + let method_ = Endpoint.method_to_string endpoint.Endpoint.method_ in + if List.mem method_ methods then methods else methods @ [ method_ ] + in + List.fold_left add [] endpoints + +let endpoints_matching_path endpoints path = + endpoints + |> List.filter (fun endpoint -> + match Path_template.match_path endpoint.Endpoint.path path with + | Ok _ -> true + | Error _ -> false) + +let method_mismatch request methods = + Error.make ~location:Error.Method + ~expected:(String.concat ", " methods) + ~got:(Endpoint.method_to_string request.Request.method_) + "HTTP method does not match any endpoint for path" + +let route_mismatch request = + Error.make ~location:Error.Route ~got:request.Request.path + "path does not match any endpoint" + +let match_request api request = + let matches, errors = + matching_endpoints api.endpoints request.Request.method_ request.path + in + match best_match matches with + | Some (endpoint, []) -> Ok endpoint + | Some (endpoint, tied) -> Error (ambiguous_match_error endpoint tied) + | None -> ( + match first_relevant_error errors with + | Some error -> Error error + | None -> ( + let allowed = + endpoints_matching_path api.endpoints request.path |> unique_methods + in + match allowed with + | [] -> Error (route_mismatch request) + | methods -> Error (method_mismatch request methods))) diff --git a/lib/api.mli b/lib/api.mli new file mode 100644 index 0000000..13c0877 --- /dev/null +++ b/lib/api.mli @@ -0,0 +1,22 @@ +(** Pure API contract made from endpoint declarations. *) + +type t + +val make : + title:string -> version:string -> Endpoint.t list -> (t, Error.t) result +(** Build an API contract. + + Exact duplicate method/path declarations are rejected. Ambiguous routes that + differ only by parameter names are also rejected. Endpoint order is + otherwise preserved for deterministic output. *) + +val title : t -> string +val version : t -> string +val endpoints : t -> Endpoint.t list + +val match_request : t -> Request.t -> (Endpoint.t, Error.t) result +(** Select the endpoint for a request without running parameter/body validation. + + Static segments are preferred over parameter segments. A request whose path + matches an endpoint with a different method reports the allowed methods for + that path. *) diff --git a/lib/contract.ml b/lib/contract.ml index a5ffdeb..c5cab22 100644 --- a/lib/contract.ml +++ b/lib/contract.ml @@ -4,6 +4,7 @@ module Codec = Codec module Json_codec = Json_codec module Path_template = Path_template module Endpoint = Endpoint +module Api = Api module Request = Request module Response = Response module Validate = Validate diff --git a/lib/contract.mli b/lib/contract.mli index 2aa0247..64a362e 100644 --- a/lib/contract.mli +++ b/lib/contract.mli @@ -6,6 +6,7 @@ module Codec = Codec module Json_codec = Json_codec module Path_template = Path_template module Endpoint = Endpoint +module Api = Api module Request = Request module Response = Response module Validate = Validate diff --git a/test/dune b/test/dune index 0455585..ab8b6b7 100644 --- a/test/dune +++ b/test/dune @@ -5,6 +5,7 @@ test_path_template test_codec test_endpoint + test_api test_json_codec test_validate test_response diff --git a/test/test_api.ml b/test/test_api.ml new file mode 100644 index 0000000..27c7b64 --- /dev/null +++ b/test/test_api.ml @@ -0,0 +1,117 @@ +open Contract + +let expect_endpoint = function + | Ok endpoint -> endpoint + | Error error -> Alcotest.fail (Error.to_string error) + +let expect_api = function + | Ok api -> api + | Error error -> Alcotest.fail (Error.to_string error) + +let expect_error_string expected = function + | Ok _ -> Alcotest.fail "expected API construction or matching to fail" + | Error error -> + Alcotest.(check string) "error" expected (Error.to_string error) + +let text_response endpoint = + Endpoint.response ~status:200 Json_codec.string endpoint + +let get_user = + Endpoint.get "/users/:id" + |> Result.map (Endpoint.path_param "id" Codec.string) + |> Result.map text_response |> expect_endpoint + +let get_current_user = + Endpoint.get "/users/me" |> Result.map text_response |> expect_endpoint + +let get_users = + Endpoint.get "/users" |> Result.map text_response |> expect_endpoint + +let post_users = + Endpoint.post "/users" |> Result.map text_response |> expect_endpoint + +let get_user_by_name = + Endpoint.get "/users/:name" + |> Result.map (Endpoint.path_param "name" Codec.string) + |> Result.map text_response |> expect_endpoint + +let api endpoints = + Api.make ~title:"Users API" ~version:"0.3.0" endpoints |> expect_api + +let make_accepts_same_path_with_different_methods () = + let api = api [ get_users; post_users ] in + Alcotest.(check int) "endpoint count" 2 (List.length (Api.endpoints api)) + +let make_rejects_duplicate_method_path () = + Api.make ~title:"Users API" ~version:"0.3.0" [ get_user; get_user ] + |> expect_error_string + "route: duplicate endpoint declaration (got: GET /users/:id)" + +let make_rejects_ambiguous_param_route_names () = + Api.make ~title:"Users API" ~version:"0.3.0" [ get_user; get_user_by_name ] + |> expect_error_string + "route: ambiguous endpoint route (got: GET /users/:id conflicts with \ + GET /users/:name)" + +let static_route_precedes_param_route () = + let api = api [ get_user; get_current_user ] in + let request = Request.make ~method_:Endpoint.GET ~path:"/users/me" () in + match Api.match_request api request with + | Ok endpoint -> + Alcotest.(check string) + "route" "/users/me" + (Path_template.raw endpoint.path) + | Error error -> Alcotest.fail (Error.to_string error) + +let param_route_matches_when_static_route_does_not () = + let api = api [ get_current_user; get_user ] in + let request = Request.make ~method_:Endpoint.GET ~path:"/users/alice" () in + match Api.match_request api request with + | Ok endpoint -> + Alcotest.(check string) + "route" "/users/:id" + (Path_template.raw endpoint.path) + | Error error -> Alcotest.fail (Error.to_string error) + +let method_mismatch_reports_allowed_methods () = + let api = api [ get_users; post_users ] in + Request.make ~method_:Endpoint.DELETE ~path:"/users" () + |> Api.match_request api + |> expect_error_string + "method: HTTP method does not match any endpoint for path (expected: \ + GET, POST, got: DELETE)" + +let no_route_match_reports_path () = + let api = api [ get_users ] in + Request.make ~method_:Endpoint.GET ~path:"/accounts" () + |> Api.match_request api + |> expect_error_string + "route: path does not match any endpoint (got: /accounts)" + +let malformed_percent_escape_is_reported () = + let api = api [ get_user ] in + Request.make ~method_:Endpoint.GET ~path:"/users/a%zz" () + |> Api.match_request api + |> expect_error_string + "path parameter id: malformed percent escape (expected: %HH, got: a%zz)" + +let tests = + ( "api", + [ + Alcotest.test_case "accepts same path with different methods" `Quick + make_accepts_same_path_with_different_methods; + Alcotest.test_case "rejects duplicate method and path" `Quick + make_rejects_duplicate_method_path; + Alcotest.test_case "rejects ambiguous param route names" `Quick + make_rejects_ambiguous_param_route_names; + Alcotest.test_case "static route precedes param route" `Quick + static_route_precedes_param_route; + Alcotest.test_case "param route matches when static route does not" `Quick + param_route_matches_when_static_route_does_not; + Alcotest.test_case "method mismatch reports allowed methods" `Quick + method_mismatch_reports_allowed_methods; + Alcotest.test_case "no route match reports path" `Quick + no_route_match_reports_path; + Alcotest.test_case "malformed percent escape is reported" `Quick + malformed_percent_escape_is_reported; + ] ) diff --git a/test/test_contract.ml b/test/test_contract.ml index 442dbb6..897d689 100644 --- a/test/test_contract.ml +++ b/test/test_contract.ml @@ -4,6 +4,7 @@ let () = Test_path_template.tests; Test_codec.tests; Test_endpoint.tests; + Test_api.tests; Test_json_codec.tests; Test_validate.tests; Test_response.tests; From 541211dade43d35cd548a36ecb342ed7fbd6f58b Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:24:11 -0700 Subject: [PATCH 3/9] Validate requests through API contracts --- lib/openapi.ml | 7 +++++++ lib/openapi.mli | 3 +++ lib/validate.ml | 5 +++++ lib/validate.mli | 4 ++++ test/test_api.ml | 13 +++++++++++++ test/test_openapi.ml | 14 +++++++++----- 6 files changed, 41 insertions(+), 5 deletions(-) diff --git a/lib/openapi.ml b/lib/openapi.ml index 50987c4..3d52f89 100644 --- a/lib/openapi.ml +++ b/lib/openapi.ml @@ -1,5 +1,12 @@ type api = { title : string; version : string; endpoints : Endpoint.t list } +let of_api api = + { + title = Api.title api; + version = Api.version api; + endpoints = Api.endpoints api; + } + let optional name = function | None -> [] | Some value -> [ (name, `String value) ] diff --git a/lib/openapi.mli b/lib/openapi.mli index 500128d..116ef32 100644 --- a/lib/openapi.mli +++ b/lib/openapi.mli @@ -2,6 +2,9 @@ type api = { title : string; version : string; endpoints : Endpoint.t list } +val of_api : Api.t -> api +(** Use a validated API contract as OpenAPI input. *) + val to_yojson : api -> Yojson.Safe.t (** Emit OpenAPI 3.0.3 JSON for the supplied endpoint list. *) diff --git a/lib/validate.ml b/lib/validate.ml index feb8f3b..f8bfc28 100644 --- a/lib/validate.ml +++ b/lib/validate.ml @@ -99,6 +99,11 @@ let request endpoint request = } else Error errors +let api_request api incoming = + match Api.match_request api incoming with + | Error error -> Error [ error ] + | Ok endpoint -> request endpoint incoming + let path validated name codec = match first_value name validated.path_values with | None -> Error (path_param_missing name) diff --git a/lib/validate.mli b/lib/validate.mli index 7a9ab87..d5bb6d4 100644 --- a/lib/validate.mli +++ b/lib/validate.mli @@ -24,6 +24,10 @@ val request : Endpoint.t -> Request.t -> (validated, Error.t list) result not present in the template is a validation error. Body field strictness is controlled by the JSON codec. *) +val api_request : Api.t -> Request.t -> (validated, Error.t list) result +(** Select an endpoint from an API contract, then validate the request against + the selected endpoint. *) + val path : validated -> string -> 'a Codec.t -> ('a, Error.t) result (** Decode a matched path parameter from a validated request. *) diff --git a/test/test_api.ml b/test/test_api.ml index 27c7b64..02c00ef 100644 --- a/test/test_api.ml +++ b/test/test_api.ml @@ -95,6 +95,17 @@ let malformed_percent_escape_is_reported () = |> expect_error_string "path parameter id: malformed percent escape (expected: %HH, got: a%zz)" +let api_request_validates_selected_endpoint () = + let api = api [ get_user ] in + let request = Request.make ~method_:Endpoint.GET ~path:"/users/alice" () in + match Validate.api_request api request with + | Error errors -> + errors |> List.map Error.to_string |> String.concat "\n" |> Alcotest.fail + | Ok validated -> ( + match Validate.path validated "id" Codec.string with + | Ok id -> Alcotest.(check string) "id" "alice" id + | Error error -> Alcotest.fail (Error.to_string error)) + let tests = ( "api", [ @@ -114,4 +125,6 @@ let tests = no_route_match_reports_path; Alcotest.test_case "malformed percent escape is reported" `Quick malformed_percent_escape_is_reported; + Alcotest.test_case "api request validates selected endpoint" `Quick + api_request_validates_selected_endpoint; ] ) diff --git a/test/test_openapi.ml b/test/test_openapi.ml index 5f21b6a..bc6da42 100644 --- a/test/test_openapi.ml +++ b/test/test_openapi.ml @@ -20,6 +20,10 @@ let expect_endpoint = function | Ok endpoint -> endpoint | Error error -> Alcotest.fail (Error.to_string error) +let expect_api = function + | Ok api -> api + | Error error -> Alcotest.fail (Error.to_string error) + let get_user = Endpoint.get ~summary:"Fetch a user" ~operation_id:"getUser" "/users/:id" |> Result.map (Endpoint.path_param "id" Codec.int) @@ -51,8 +55,8 @@ let api : Openapi.api = endpoints = [ get_user; post_user ]; } -let tiny_users_api : Openapi.api = - { title = "Tiny Users API"; version = "0.2.0"; endpoints = [ get_user ] } +let tiny_users_contract_api = + Api.make ~title:"Tiny Users API" ~version:"0.2.0" [ get_user ] |> expect_api let member name = function | `Assoc fields -> List.assoc_opt name fields @@ -203,7 +207,7 @@ let output_is_deterministic () = let output_matches_tiny_users_fixture () = let expected = Yojson.Safe.from_file "fixtures/openapi_tiny_users_api.json" in - let actual = Openapi.to_yojson tiny_users_api in + let actual = tiny_users_contract_api |> Openapi.of_api |> Openapi.to_yojson in Alcotest.(check string) "openapi fixture" (Yojson.Safe.to_string expected) @@ -212,8 +216,8 @@ let output_matches_tiny_users_fixture () = let tiny_users_fixture_output_is_deterministic () = Alcotest.(check string) "tiny users openapi" - (Openapi.to_string tiny_users_api) - (Openapi.to_string tiny_users_api) + (Openapi.to_string (Openapi.of_api tiny_users_contract_api)) + (Openapi.to_string (Openapi.of_api tiny_users_contract_api)) let output_escapes_strings () = let endpoint = From 7e991be1b209a62822b449bebf481edabd402db0 Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:27:17 -0700 Subject: [PATCH 4/9] Document API contract routing --- CHANGES.md | 4 +++- README.md | 4 ++-- examples/users_api.ml | 20 +++++++++----------- test/fixtures/openapi_tiny_users_api.json | 2 +- test/test_openapi.ml | 10 +++++----- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 02dc1f2..8146e63 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,7 +2,9 @@ ## 0.3.0 - Unreleased -- Start 0.3.0 development. +- Add pure API contracts for endpoint route tables. +- Add API-level request matching and validation. +- Allow OpenAPI output from validated API contracts. ## 0.2.0 - 2026-06-09 diff --git a/README.md b/README.md index bd2774a..fa2c781 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ [![opam](https://badgen.net/opam/v/contract)](https://opam.ocaml.org/packages/contract/) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -`contract` is an OCaml library for describing HTTP API contracts as typed values. The current code covers a small pure core: endpoint definitions, path matching, scalar and JSON decoding, request and response validation, and OpenAPI output. +`contract` is an OCaml library for describing HTTP API contracts as typed values. The current code covers a small pure core: endpoint definitions, API route matching, scalar and JSON decoding, request and response validation, and OpenAPI output. ## Current MVP The current source tree is a thin vertical slice for REST-style JSON APIs. It has no HTTP server dependency. -A request is a value passed to the validator; a response is a status plus optional JSON body checked against the endpoint's declared responses. +Endpoints can be grouped into an API value for pure route selection. A request is a value passed to the endpoint or API validator; a response is a status plus optional JSON body checked against the endpoint's declared responses. Path parameters are percent-decoded after route matching. Released package: diff --git a/examples/users_api.ml b/examples/users_api.ml index f1c7a15..033416a 100644 --- a/examples/users_api.ml +++ b/examples/users_api.ml @@ -5,8 +5,8 @@ type create_user = { email : string; name : string option } let ( let* ) = Result.bind -let endpoint_or_exit = function - | Ok endpoint -> endpoint +let or_exit = function + | Ok value -> value | Error error -> prerr_endline (Error.to_string error); exit 1 @@ -59,19 +59,17 @@ let get_user = |> Result.map (Endpoint.path_param "id" Codec.int) |> Result.map (Endpoint.query_param "include_deleted" Codec.bool) |> Result.map (Endpoint.response ~status:200 user_codec) - |> endpoint_or_exit + |> or_exit let create_user = Endpoint.post ~summary:"Create a user" ~operation_id:"createUser" "/users" |> Result.map (Endpoint.body create_user_codec) |> Result.map (Endpoint.response ~status:201 user_codec) - |> endpoint_or_exit + |> or_exit -let api : Openapi.api = - { - title = "Users API"; - version = "0.2.0"; - endpoints = [ get_user; create_user ]; - } +let api = + Api.make ~title:"Users API" ~version:"0.3.0" [ get_user; create_user ] + |> or_exit -let () = print_endline (Openapi.to_string ~pretty:true api) +let () = + api |> Openapi.of_api |> Openapi.to_string ~pretty:true |> print_endline diff --git a/test/fixtures/openapi_tiny_users_api.json b/test/fixtures/openapi_tiny_users_api.json index 6175987..0ba6de2 100644 --- a/test/fixtures/openapi_tiny_users_api.json +++ b/test/fixtures/openapi_tiny_users_api.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "Tiny Users API", - "version": "0.2.0" + "version": "0.3.0" }, "paths": { "/users/{id}": { diff --git a/test/test_openapi.ml b/test/test_openapi.ml index bc6da42..00a51b8 100644 --- a/test/test_openapi.ml +++ b/test/test_openapi.ml @@ -51,12 +51,12 @@ let create_session = let api : Openapi.api = { title = "Users API"; - version = "0.2.0"; + version = "0.3.0"; endpoints = [ get_user; post_user ]; } let tiny_users_contract_api = - Api.make ~title:"Tiny Users API" ~version:"0.2.0" [ get_user ] |> expect_api + Api.make ~title:"Tiny Users API" ~version:"0.3.0" [ get_user ] |> expect_api let member name = function | `Assoc fields -> List.assoc_opt name fields @@ -115,7 +115,7 @@ let same_path_get_and_post_share_one_path_item () = Openapi.to_yojson { title = "Sessions API"; - version = "0.2.0"; + version = "0.3.0"; endpoints = [ list_sessions; create_session ]; } in @@ -228,7 +228,7 @@ let output_escapes_strings () = in let json = Openapi.to_string - { title = "Quoted \"API\""; version = "0.2.0"; endpoints = [ endpoint ] } + { title = "Quoted \"API\""; version = "0.3.0"; endpoints = [ endpoint ] } |> Yojson.Safe.from_string in let info = require_member "info" json in @@ -242,7 +242,7 @@ let output_escapes_strings () = let empty_api_has_empty_paths () = let json = - Openapi.to_yojson { title = "Empty API"; version = "0.2.0"; endpoints = [] } + Openapi.to_yojson { title = "Empty API"; version = "0.3.0"; endpoints = [] } in match require_member "paths" json with | `Assoc [] -> () From d5851a5352843aad863a0d31c64ddbcca0be96e9 Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:15:38 -0700 Subject: [PATCH 5/9] Add HTTP-style adapter example --- examples/dune | 4 +- examples/http_style_app.ml | 307 +++++++++++++++++++++++++++++++++++++ 2 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 examples/http_style_app.ml diff --git a/examples/dune b/examples/dune index afd2c42..16f531c 100644 --- a/examples/dune +++ b/examples/dune @@ -1,3 +1,3 @@ -(executable - (name users_api) +(executables + (names users_api http_style_app) (libraries contract yojson)) diff --git a/examples/http_style_app.ml b/examples/http_style_app.ml new file mode 100644 index 0000000..07a3256 --- /dev/null +++ b/examples/http_style_app.ml @@ -0,0 +1,307 @@ +open Contract + +type user = { id : int; email : string; name : string option } +type create_user = { email : string; name : string option } +type error_payload = { code : string; detail : string } + +type request = { + meth : string; + path : string; + query : (string * string list) list; + headers : (string * string) list; + body : Yojson.Safe.t option; +} + +type response = { + status : int; + headers : (string * string) list; + body : Yojson.Safe.t option; +} + +type route = { endpoint : Endpoint.t; handle : Validate.validated -> response } + +let ( let* ) = Result.bind + +let or_exit = function + | Ok value -> value + | Error error -> + prerr_endline (Error.to_string error); + exit 1 + +let json_headers = [ ("content-type", "application/json") ] + +let assoc_with_optional_name fields = function + | None -> `Assoc fields + | Some name -> `Assoc (fields @ [ ("name", `String name) ]) + +let user_schema = + Schema.obj + [ + ("id", Schema.integer, true); + ("email", Schema.string, true); + ("name", Schema.string, false); + ] + +let user_codec = + let encode user = + assoc_with_optional_name + [ ("id", `Int user.id); ("email", `String user.email) ] + user.name + in + let decode json = + let* id = Json_codec.required_field "id" Json_codec.int json in + let* email = Json_codec.required_field "email" Json_codec.string json in + let* name = Json_codec.optional_field "name" Json_codec.string json in + Ok { id; email; name } + in + Json_codec.make ~name:"User" ~schema:user_schema ~encode ~decode () + +let create_user_schema = + Schema.obj [ ("email", Schema.string, true); ("name", Schema.string, false) ] + +let create_user_codec = + let encode create_user = + assoc_with_optional_name + [ ("email", `String create_user.email) ] + create_user.name + in + let decode json = + let* email = Json_codec.required_field "email" Json_codec.string json in + let* name = Json_codec.optional_field "name" Json_codec.string json in + Ok { email; name } + in + Json_codec.make ~name:"CreateUser" ~schema:create_user_schema ~encode ~decode + () + +let error_schema = + Schema.obj [ ("code", Schema.string, true); ("detail", Schema.string, true) ] + +let error_codec = + let encode error = + `Assoc [ ("code", `String error.code); ("detail", `String error.detail) ] + in + let decode json = + let* code = Json_codec.required_field "code" Json_codec.string json in + let* detail = Json_codec.required_field "detail" Json_codec.string json in + Ok { code; detail } + in + Json_codec.make ~name:"Error" ~schema:error_schema ~encode ~decode () + +let get_current_user = + Endpoint.get ~summary:"Fetch the current user" ~operation_id:"getCurrentUser" + "/users/me" + |> Result.map (Endpoint.response ~status:200 user_codec) + |> or_exit + +let get_user = + Endpoint.get ~summary:"Fetch a user" ~operation_id:"getUser" "/users/:id" + |> Result.map (Endpoint.path_param "id" Codec.int) + |> Result.map (Endpoint.query_param "include_deleted" Codec.bool) + |> Result.map (Endpoint.response ~status:200 user_codec) + |> Result.map (Endpoint.response ~status:404 error_codec) + |> or_exit + +let create_user = + Endpoint.post ~summary:"Create a user" ~operation_id:"createUser" "/users" + |> Result.map (Endpoint.body create_user_codec) + |> Result.map (Endpoint.response ~status:201 user_codec) + |> or_exit + +let error_json code detail = error_codec.Json_codec.encode { code; detail } + +let user_response ~status user = + { status; headers = json_headers; body = Some (user_codec.encode user) } + +let adapter_error status code errors = + let details = + errors |> List.map (fun error -> `String (Error.to_string error)) + in + { + status; + headers = json_headers; + body = Some (`Assoc [ ("error", `String code); ("details", `List details) ]); + } + +let request_error_response = function + | [] -> adapter_error 500 "adapter_error" [] + | error :: _ as errors -> ( + match error.Error.location with + | Error.Method -> adapter_error 405 "method_not_allowed" errors + | Error.Route -> adapter_error 404 "not_found" errors + | Error.Path_param _ | Query_param _ | Body | Json_field _ -> + adapter_error 400 "bad_request" errors + | Error.Status -> adapter_error 500 "response_validation_failed" errors) + +let response_error_response errors = + adapter_error 500 "response_validation_failed" errors + +let internal_error message = + let error = Error.make ~location:Error.Body message in + adapter_error 500 "adapter_error" [ error ] + +let current_user_handler _validated = + user_response ~status:200 + { id = 1; email = "me@example.test"; name = Some "Current User" } + +let get_user_handler validated = + match + ( Validate.path validated "id" Codec.int, + Validate.query validated "include_deleted" Codec.bool ) + with + | Ok 404, _ -> + { + status = 404; + headers = json_headers; + body = Some (error_json "not_found" "user not found"); + } + | Ok 500, _ -> + { + status = 500; + headers = json_headers; + body = Some (error_json "storage_failed" "upstream lookup failed"); + } + | Ok 2, _ -> + { + status = 200; + headers = json_headers; + body = + Some + (`Assoc + [ + ("id", `String "2"); + ("email", `String "broken@example.test"); + ("name", `String "Broken User"); + ]); + } + | Ok id, Ok include_deleted -> + let name = + match include_deleted with + | Some true -> Some "Visible Deleted User" + | _ -> Some "Example User" + in + user_response ~status:200 + { id; email = "user" ^ string_of_int id ^ "@example.test"; name } + | Error error, _ | _, Error error -> + internal_error ("handler decode failed: " ^ Error.to_string error) + +let create_user_handler validated = + match Validate.body validated create_user_codec with + | Ok (Some input) -> + user_response ~status:201 + { id = 100; email = input.email; name = input.name } + | Ok None -> internal_error "handler expected a decoded request body" + | Error error -> + internal_error ("handler decode failed: " ^ Error.to_string error) + +let routes = + [ + { endpoint = get_current_user; handle = current_user_handler }; + { endpoint = get_user; handle = get_user_handler }; + { endpoint = create_user; handle = create_user_handler }; + ] + +let api = + routes + |> List.map (fun route -> route.endpoint) + |> Api.make ~title:"HTTP Style Users API" ~version:"0.3.0" + |> or_exit + +let method_of_string = function + | "GET" -> Ok Endpoint.GET + | "POST" -> Ok Endpoint.POST + | "PUT" -> Ok Endpoint.PUT + | "PATCH" -> Ok Endpoint.PATCH + | "DELETE" -> Ok Endpoint.DELETE + | meth -> + Error + (Error.make ~location:Error.Method ~got:meth "unsupported HTTP method") + +let flatten_query query = + List.concat_map + (fun (name, values) -> List.map (fun value -> (name, value)) values) + query + +let contract_request (request : request) = + let _headers = request.headers in + match method_of_string request.meth with + | Error error -> Error error + | Ok method_ -> + let query = flatten_query request.query in + Ok + (match request.body with + | None -> Request.make ~method_ ~path:request.path ~query () + | Some body -> Request.make ~method_ ~path:request.path ~query ~body ()) + +let same_endpoint left right = + left.Endpoint.method_ = right.Endpoint.method_ + && String.equal + (Path_template.raw left.Endpoint.path) + (Path_template.raw right.Endpoint.path) + +let route_for endpoint = + routes |> List.find_opt (fun route -> same_endpoint route.endpoint endpoint) + +let contract_response (response : response) = + match response.body with + | None -> Response.make ~status:response.status () + | Some body -> Response.make ~status:response.status ~body () + +let dispatch request = + match contract_request request with + | Error error -> request_error_response [ error ] + | Ok incoming -> ( + match Validate.api_request api incoming with + | Error errors -> request_error_response errors + | Ok validated -> ( + match route_for validated.endpoint with + | None -> internal_error "validated endpoint has no handler" + | Some route -> ( + let response = route.handle validated in + match + Validate.response validated.endpoint + (contract_response response) + with + | Ok _ -> response + | Error errors -> response_error_response errors))) + +let response_to_yojson (response : response) = + let headers = + response.headers + |> List.map (fun (name, value) -> + `Assoc [ ("name", `String name); ("value", `String value) ]) + in + `Assoc + [ + ("status", `Int response.status); + ("headers", `List headers); + ("body", Option.value response.body ~default:`Null); + ] + +let print_json label json = + print_endline ("== " ^ label ^ " =="); + print_endline (Yojson.Safe.pretty_to_string json) + +let run_case label request = + dispatch request |> response_to_yojson |> print_json label + +let request ?(query = []) ?body meth path = + { meth; path; query; headers = []; body } + +let () = + api |> Openapi.of_api |> Openapi.to_yojson |> print_json "openapi"; + run_case "static route" (request "GET" "/users/me"); + run_case "path and query params" + (request "GET" "/users/42" ~query:[ ("include_deleted", [ "true" ]) ]); + run_case "json request body" + (request "POST" "/users" + ~body: + (`Assoc + [ ("email", `String "new@example.test"); ("name", `String "New") ])); + run_case "no route match" (request "GET" "/accounts"); + run_case "bad path parameter" (request "GET" "/users/not-an-int"); + run_case "bad query parameter" + (request "GET" "/users/42" ~query:[ ("include_deleted", [ "sometimes" ]) ]); + run_case "invalid json body" + (request "POST" "/users" ~body:(`Assoc [ ("email", `Int 1) ])); + run_case "invalid response body" (request "GET" "/users/2"); + run_case "invalid response status" (request "GET" "/users/500") From 4d5d96e5213bace8beee8c5d8bfe6e02ac275d6c Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:17:59 -0700 Subject: [PATCH 6/9] Document HTTP-style adapter example --- CHANGES.md | 2 ++ README.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 8146e63..5e8f47a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,8 @@ - Add pure API contracts for endpoint route tables. - Add API-level request matching and validation. - Allow OpenAPI output from validated API contracts. +- Add an example-only HTTP-style adapter around API validation and response + validation. ## 0.2.0 - 2026-06-09 diff --git a/README.md b/README.md index fa2c781..963651d 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,11 @@ opam install contract ``` See `examples/users_api.ml` for a small users API with `GET /users/:id` and `POST /users`. +See `examples/http_style_app.ml` for an example-only HTTP-style dispatch wrapper around the pure API validator. ```sh dune exec examples/users_api.exe +dune exec examples/http_style_app.exe ``` Development: From d809d0565c56ac13287c101c3f7ec1c1609e94d4 Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:30:07 -0700 Subject: [PATCH 7/9] Polish 0.3 release metadata --- RELEASE.md | 14 ++++++++------ contract.opam | 3 ++- dune-project | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 20fd0bc..af2e9a6 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -13,22 +13,24 @@ opam exec -- dune build -p contract opam exec -- dune runtest -p contract opam lint contract.opam opam exec -- dune exec examples/users_api.exe +opam exec -- dune exec examples/http_style_app.exe ``` -Check that the example prints OpenAPI JSON with: +Check that the examples print OpenAPI JSON with: - `"openapi": "3.0.3"` - `/users/{id}` - `GET /users/{id}` - `POST /users` -For 0.2.0: +When cutting a release: ```sh -git tag -a 0.2.0 -m "Release 0.2.0" -git push origin 0.2.0 -opam publish --tag 0.2.0 -v 0.2.0 . +git tag -a -m "Release " +git push origin +opam publish --tag -v . ``` Use the tag created from the checked release commit. If `opam publish` cannot be -used from this machine, open an opam-repository pull request for `packages/contract/contract.0.2.0/opam`. +used from this machine, open an opam-repository pull request for the package +version being released. diff --git a/contract.opam b/contract.opam index e65faa6..de7f646 100644 --- a/contract.opam +++ b/contract.opam @@ -11,7 +11,8 @@ synopsis: "Typed HTTP API contracts for OCaml" description: """ contract describes REST-style HTTP API contracts as typed OCaml values. The current package provides a pure core for endpoint definitions, parameter -and JSON decoding, request and response validation, and OpenAPI 3.0.3 output. +and JSON decoding, route matching, request and response validation, and +OpenAPI 3.0.3 output. """ depends: [ "ocaml" {>= "5.0"} diff --git a/dune-project b/dune-project index f01268c..bfc2fe9 100644 --- a/dune-project +++ b/dune-project @@ -7,7 +7,7 @@ (name contract) (synopsis "Typed HTTP API contracts for OCaml") (description - "A pure core for typed HTTP API contracts, request and response validation, and OpenAPI output.") + "A pure core for typed HTTP API contracts, route matching, request and response validation, and OpenAPI output.") (license MIT) (depends (ocaml (>= 5.0)) From d2b2ea05ae7035ac23318a9ba587f7b0c17b0163 Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:32:47 -0700 Subject: [PATCH 8/9] Run examples in CI --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d417bc..26774ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,5 +25,10 @@ jobs: - name: Test run: opam exec -- dune runtest + - name: Run examples + run: | + opam exec -- dune exec examples/users_api.exe + opam exec -- dune exec examples/http_style_app.exe + - name: Lint opam file run: opam lint contract.opam From 5e1a04c9db065f68029d16e2a498014e1882ddae Mon Sep 17 00:00:00 2001 From: funwithcthulhu <29905917+funwithcthulhu@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:34:38 -0700 Subject: [PATCH 9/9] Tighten 0.3 README scope wording --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 963651d..ebe13e5 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ `contract` is an OCaml library for describing HTTP API contracts as typed values. The current code covers a small pure core: endpoint definitions, API route matching, scalar and JSON decoding, request and response validation, and OpenAPI output. -## Current MVP +## Scope -The current source tree is a thin vertical slice for REST-style JSON APIs. It has no HTTP server dependency. +`contract` currently targets REST-style JSON APIs. It has no HTTP server dependency. Endpoints can be grouped into an API value for pure route selection. A request is a value passed to the endpoint or API validator; a response is a status plus optional JSON body checked against the endpoint's declared responses. Path parameters are percent-decoded after route matching. @@ -19,7 +19,7 @@ opam install contract ``` See `examples/users_api.ml` for a small users API with `GET /users/:id` and `POST /users`. -See `examples/http_style_app.ml` for an example-only HTTP-style dispatch wrapper around the pure API validator. +See `examples/http_style_app.ml` for example-only HTTP-style glue around the pure API validator. ```sh dune exec examples/users_api.exe