├── .cljfmt.edn ├── .gitignore ├── src └── navi │ ├── protocols.clj │ ├── core.clj │ ├── impl.clj │ └── transform.clj ├── deps.edn ├── .github └── workflows │ └── ci.yaml ├── LICENSE ├── test ├── navi │ ├── core_test.clj │ ├── impl_test.clj │ └── transform_test.clj └── api.yaml └── README.md /.cljfmt.edn: -------------------------------------------------------------------------------- 1 | {:remove-multiple-non-indenting-spaces? true 2 | :sort-ns-references? true} 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cpcache/ 2 | *.jar 3 | pom.xml 4 | pom.xml.asc 5 | .nrepl-port 6 | .lsp/ 7 | .DS_Store 8 | target/ 9 | .idea/ 10 | *.iml 11 | .cache/ 12 | .clj-kondo/ 13 | *.pom.asc 14 | -------------------------------------------------------------------------------- /src/navi/protocols.clj: -------------------------------------------------------------------------------- 1 | ; Copyright 2021- Rahul De 2 | ; 3 | ; Use of this source code is governed by an MIT-style 4 | ; license that can be found in the LICENSE file or at 5 | ; https://opensource.org/licenses/MIT. 6 | 7 | (ns navi.protocols) 8 | 9 | (defprotocol Transformable 10 | (transform [_])) 11 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | ; Copyright 2021- Rahul De 2 | ; 3 | ; Use of this source code is governed by an MIT-style 4 | ; license that can be found in the LICENSE file or at 5 | ; https://opensource.org/licenses/MIT. 6 | 7 | {:deps {io.swagger.parser.v3/swagger-parser {:mvn/version "2.1.35"}} 8 | :aliases {:test {:extra-paths ["test"] 9 | :extra-deps {io.github.cognitect-labs/test-runner {:git/tag "v0.5.1" :git/sha "dfb30dd"} 10 | metosin/malli {:mvn/version "0.20.0"}} 11 | :main-opts ["-m" "cognitect.test-runner"] 12 | :exec-fn cognitect.test-runner.api/test} 13 | :build {:deps {io.github.clojure/tools.build {:git/tag "v0.10.11" :git/sha "c6c670a"} 14 | slipset/deps-deploy {:mvn/version "0.2.2"}} 15 | :ns-default build}}} 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2021- Rahul De 2 | # 3 | # Use of this source code is governed by an MIT-style 4 | # license that can be found in the LICENSE file or at 5 | # https://opensource.org/licenses/MIT. 6 | 7 | name: Tests 8 | 9 | on: 10 | push: 11 | paths-ignore: 12 | - "**.md" 13 | 14 | jobs: 15 | build: 16 | runs-on: "ubuntu-latest" 17 | 18 | steps: 19 | - name: "Checkout code" 20 | uses: "actions/checkout@v5" 21 | 22 | - name: "Prepare Java" 23 | uses: "actions/setup-java@v5" 24 | with: 25 | distribution: 'temurin' 26 | java-version: '25' 27 | 28 | - name: "Prepare tools-deps" 29 | uses: "DeLaGuardo/setup-clojure@master" 30 | with: 31 | cli: latest 32 | 33 | - name: "Apply Cache" 34 | uses: "actions/cache@v4" 35 | with: 36 | path: | 37 | ~/.m2/repository 38 | key: "${{ runner.os }}-navi-${{ hashFiles('deps.edn') }}" 39 | restore-keys: "${{ runner.os }}-navi-" 40 | 41 | - name: "Run tests" 42 | run: "clojure -X:test" 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020- Rahul De 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/navi/core.clj: -------------------------------------------------------------------------------- 1 | ; Copyright 2021- Rahul De 2 | ; 3 | ; Use of this source code is governed by an MIT-style 4 | ; license that can be found in the LICENSE file or at 5 | ; https://opensource.org/licenses/MIT. 6 | 7 | (ns navi.core 8 | (:require 9 | [navi.impl :as i] 10 | [navi.transform]) ;; TODO: Can this be improved? 11 | (:import 12 | [io.swagger.v3.parser OpenAPIV3Parser] 13 | [io.swagger.v3.parser.core.models ParseOptions])) 14 | 15 | (defn routes-from 16 | "Takes in the OpenAPI JSON/YAML as string and a map of OperationId to handler fns. 17 | Returns the reitit route map with malli schemas" 18 | [^String api-spec handlers] 19 | (let [parse-options (doto (ParseOptions.) 20 | (.setResolveFully true))] 21 | (->> (.readContents (OpenAPIV3Parser.) api-spec nil parse-options) 22 | .getOpenAPI 23 | .getPaths 24 | (mapv (fn [[path item]] 25 | [path (i/path-item->data item handlers)]))))) 26 | 27 | (comment 28 | (require '[clojure.pprint :as pp]) 29 | 30 | (set! *warn-on-reflection* true) 31 | 32 | (def handlers 33 | {"AddGet" (fn [{{{:keys [n1 n2]} :path} :parameters}] 34 | {:status 200 35 | :body (+ n1 n2)}) 36 | "AddPost" (fn [{{{:keys [n1 n2]} :body} :parameters}] 37 | {:status 200 38 | :body (+ n1 n2)}) 39 | "HealthCheck" (fn [_] 40 | {:status 200 41 | :body "Ok"})}) 42 | (-> "test/api.yaml" 43 | slurp 44 | (routes-from handlers) 45 | pp/pprint)) 46 | -------------------------------------------------------------------------------- /test/navi/core_test.clj: -------------------------------------------------------------------------------- 1 | ; Copyright 2021- Rahul De 2 | ; 3 | ; Use of this source code is governed by an MIT-style 4 | ; license that can be found in the LICENSE file or at 5 | ; https://opensource.org/licenses/MIT. 6 | 7 | (ns navi.core-test 8 | (:require 9 | [clojure.test :refer [deftest is testing]] 10 | [navi.core :as c])) 11 | 12 | (deftest full-test 13 | (testing "full route tree" 14 | (is (= (c/routes-from (slurp "test/api.yaml") 15 | {"GetIdAndVersion" identity 16 | "DeleteIdAndVersion" identity 17 | "PostId" identity 18 | "HealthCheck" identity 19 | "GetInfoAtTime" identity 20 | "GetInclusiveIntervalInteger" identity 21 | "GetInclusiveIntervalNumber" identity 22 | "GetMinMaxNumber" identity 23 | "RunV2GraphQLQuery" identity 24 | "ProvideRawData" identity}) 25 | [["/get/{id}/and/{version}" 26 | {:get 27 | {:handler identity 28 | :parameters 29 | {:path 30 | [:map 31 | [:id string?] 32 | [:version int?]]}} 33 | :delete 34 | {:handler identity 35 | :parameters 36 | {:path 37 | [:map 38 | [:id string?] 39 | [:version int?]]}}}] 40 | ["/post/{id}" 41 | {:post 42 | {:handler identity 43 | :parameters 44 | {:body 45 | [:map 46 | {:closed false} 47 | [:foo uuid?] 48 | [:bar inst?] 49 | [:baz [:sequential number?]]]}}}] 50 | ["/health" 51 | {:get 52 | {:handler identity 53 | :parameters 54 | {:cookie 55 | [:map 56 | [:last-state 57 | {:optional true} 58 | string?]]}}}] 59 | ["/info/at/{time}" 60 | {:get 61 | {:handler identity 62 | :parameters 63 | {:path [:map [:time inst?]] 64 | :query 65 | [:map 66 | [:verbose {:optional true} boolean?] 67 | [:foo {:optional true} [:or string? int?]] 68 | [:bar {:optional true} [:and int? uuid?]]]}}}] 69 | ["/v1/inclusive-interval-integer" 70 | {:get 71 | {:handler identity 72 | :parameters 73 | {:query 74 | [:map 75 | [:lower 76 | {:optional true} 77 | [:and int? [:>= 0M]]] 78 | [:upper 79 | {:optional true} 80 | [:and int? [:<= 119M]]]]}}}] 81 | ["/v1/inclusive-interval-number" 82 | {:get 83 | {:handler identity 84 | :parameters 85 | {:query 86 | [:map 87 | [:lower 88 | {:optional true} 89 | [:and number? [:>= 0M]]] 90 | [:upper 91 | {:optional true} 92 | [:and number? [:<= 119M]]]]}}}] 93 | ["/v1/min-max" 94 | {:get 95 | {:handler identity 96 | :parameters 97 | {:query 98 | [:map 99 | [:num 100 | {:optional true} 101 | [:and number? [:>= 0M] [:<= 100M]]]]}}}] 102 | ["/v2/graphql" 103 | {:post 104 | {:handler identity 105 | :parameters 106 | {:form 107 | [:map 108 | {:closed false} 109 | [:query string?] 110 | [:variables 111 | {:optional true} 112 | string?] 113 | [:operationName 114 | {:optional true} 115 | string?]]}}}] 116 | ["/raw" 117 | {:post 118 | {:handler identity 119 | :parameters 120 | {:body 121 | [:or nil? bytes?]}}}]])))) 122 | -------------------------------------------------------------------------------- /test/api.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.1.1" 2 | 3 | info: 4 | title: My API 5 | version: "1.0" 6 | description: My awesome API 7 | 8 | paths: 9 | "/get/{id}/and/{version}": 10 | get: 11 | operationId: GetIdAndVersion 12 | summary: Gets Id and Version as Path params 13 | 14 | parameters: 15 | - name: id 16 | required: true 17 | in: path 18 | description: The id 19 | schema: 20 | type: string 21 | - name: version 22 | required: true 23 | in: path 24 | description: The version 25 | schema: 26 | type: integer 27 | 28 | delete: 29 | operationId: DeleteIdAndVersion 30 | summary: Deletes Id and Version as Path params 31 | 32 | parameters: 33 | - name: id 34 | required: true 35 | in: path 36 | schema: 37 | type: string 38 | - name: version 39 | required: true 40 | in: path 41 | schema: 42 | type: integer 43 | 44 | "/post/{id}": 45 | post: 46 | operationId: PostId 47 | summary: Post Id and Payload 48 | 49 | requestBody: 50 | description: The payload 51 | required: true 52 | content: 53 | application/json: 54 | schema: 55 | $ref: "#/components/schemas/Payload" 56 | 57 | "/health": 58 | get: 59 | operationId: HealthCheck 60 | summary: Returns Ok if all is well 61 | 62 | parameters: 63 | - name: last-state 64 | in: cookie 65 | schema: 66 | type: string 67 | 68 | "/info/at/{time}": 69 | get: 70 | operationId: GetInfoAtTime 71 | summary: Gets Info at time 72 | 73 | parameters: 74 | - name: time 75 | required: true 76 | in: path 77 | schema: 78 | type: string 79 | format: date 80 | - name: verbose 81 | in: query 82 | schema: 83 | type: boolean 84 | - name: foo 85 | in: query 86 | schema: 87 | anyOf: 88 | - type: string 89 | - type: integer 90 | - name: bar 91 | in: query 92 | schema: 93 | allOf: 94 | - type: integer 95 | - type: string 96 | format: uuid 97 | "/v1/inclusive-interval-integer": 98 | get: 99 | operationId: GetInclusiveIntervalInteger 100 | summary: Get interval data given lower and upper bounds 101 | parameters: 102 | - name: lower 103 | in: query 104 | required: false 105 | schema: 106 | type: integer 107 | format: int64 108 | minimum: 0 109 | - name: upper 110 | in: query 111 | required: false 112 | schema: 113 | type: integer 114 | format: int64 115 | maximum: 119 116 | "/v1/inclusive-interval-number": 117 | get: 118 | operationId: GetInclusiveIntervalNumber 119 | summary: Get interval data given lower and upper bounds 120 | parameters: 121 | - name: lower 122 | in: query 123 | required: false 124 | schema: 125 | type: number 126 | format: double 127 | minimum: 0 128 | - name: upper 129 | in: query 130 | required: false 131 | schema: 132 | type: number 133 | format: double 134 | maximum: 119 135 | "/v1/min-max": 136 | get: 137 | operationId: GetMinMaxNumber 138 | summary: Get a min max number 139 | parameters: 140 | - name: num 141 | in: query 142 | required: false 143 | schema: 144 | type: number 145 | format: double 146 | minimum: 0 147 | maximum: 100 148 | "/v2/graphql": 149 | post: 150 | operationId: RunV2GraphQLQuery 151 | summary: Run a GraphQL query using POST. 152 | requestBody: 153 | description: "The query data" 154 | required: true 155 | content: 156 | application/x-www-form-urlencoded: 157 | schema: 158 | type: object 159 | properties: 160 | query: 161 | type: string 162 | description: "query string" 163 | variables: 164 | type: string 165 | description: "json string with query variables" 166 | operationName: 167 | type: string 168 | description: "optional query name" 169 | required: 170 | - query 171 | "/raw": 172 | post: 173 | operationId: ProvideRawData 174 | requestBody: 175 | content: 176 | application/octet-stream: 177 | schema: 178 | type: string 179 | format: binary 180 | 181 | components: 182 | schemas: 183 | Payload: 184 | type: object 185 | required: 186 | - foo 187 | - bar 188 | - baz 189 | properties: 190 | foo: 191 | type: string 192 | format: uuid 193 | bar: 194 | type: string 195 | format: date-time 196 | baz: 197 | type: array 198 | items: 199 | type: number 200 | -------------------------------------------------------------------------------- /src/navi/impl.clj: -------------------------------------------------------------------------------- 1 | ; Copyright 2021- Rahul De 2 | ; 3 | ; Use of this source code is governed by an MIT-style 4 | ; license that can be found in the LICENSE file or at 5 | ; https://opensource.org/licenses/MIT. 6 | 7 | (ns navi.impl 8 | (:require 9 | [navi.protocols :as p]) 10 | (:import 11 | [io.swagger.v3.oas.models Operation PathItem PathItem$HttpMethod] 12 | [io.swagger.v3.oas.models.media MediaType] 13 | [io.swagger.v3.oas.models.parameters Parameter] 14 | [io.swagger.v3.oas.models.responses ApiResponse] 15 | [java.util Map$Entry])) 16 | 17 | ;; TODO: Better 18 | (defn ->prop-schema 19 | "Given a property and a required keys set, returns a malli spec. 20 | Intended for RequestBody" 21 | [required ^Map$Entry property] 22 | (let [k (.getKey property) 23 | key-schema [(keyword k)] 24 | key-schema (if (contains? required k) 25 | key-schema 26 | (conj key-schema {:optional true}))] 27 | (conj key-schema 28 | (-> property 29 | .getValue 30 | p/transform)))) 31 | 32 | (defn ->param-schema 33 | "Given a param applies the similar logic as prop to schema 34 | Intended for Parameter" 35 | [^Parameter param] 36 | (let [key-spec [(-> param 37 | .getName 38 | keyword)] 39 | key-spec (if (.getRequired param) 40 | key-spec 41 | (conj key-spec {:optional true}))] 42 | (conj key-spec 43 | (-> param 44 | .getSchema 45 | p/transform)))) 46 | 47 | ;; TODO: Better 48 | (defn wrap-map 49 | "Surrounds the key in a map for malli conformance" 50 | [k m] 51 | (cond-> m 52 | (contains? m k) 53 | (update-in [k] #(into [:map] %)))) 54 | 55 | (defn update-kvs 56 | "Update a map using `key-fn` and `val-fn`. 57 | Sort of like composing `update-keys` and `update-vals`. 58 | Unlike `update-keys` or `update-vals`, preserve `nil`s." 59 | [m key-fn val-fn] 60 | (when m 61 | (reduce-kv (fn kv-mapper [m k v] 62 | (assoc m (key-fn k) (val-fn v))) 63 | {} 64 | m))) 65 | 66 | (defn handle-response-key 67 | "Reitit seems to want status codes of a response to be integer keys, 68 | rather than keyword keys or string keys (except for `:default`). 69 | So, convert a string to a Long if relevant. 70 | Else if the string is \"default\", then return `:default`, otherwise pass through. 71 | Arguably, all non-integer status codes should be converted to keywords." 72 | [s] 73 | (cond (re-matches #"\d{3}" s) (Long/parseLong s) 74 | (= "default" s) :default 75 | :else s)) 76 | 77 | (defn media-type->data 78 | "Convert a Java Schema's MediaType to a spec that Reitit will accept." 79 | [^MediaType mt] 80 | (if-let [schema (some-> mt .getSchema p/transform)] 81 | {:schema schema} 82 | (throw (ex-info "MediaType has no schema" {:media-type mt})))) 83 | 84 | (defn handle-media-type-key 85 | "If the media type is \"default\", then return it as a keyword, otherwise pass through." 86 | [s] 87 | (if (= "default" s) 88 | :default 89 | s)) 90 | 91 | (defn response->data 92 | "Convert an ApiResponse to a response conforming to reitit." 93 | [^ApiResponse response] 94 | (let [orig-content (.getContent response) 95 | ;; If no content then use the nil? schema with a default media type. 96 | ;; This is a work-around for a current Reitit bug. 97 | ;; See https://github.com/metosin/reitit/issues/691 98 | content (if orig-content 99 | (update-kvs orig-content handle-media-type-key media-type->data) 100 | {:default {:schema nil?}}) 101 | description (.getDescription response)] 102 | ;; TODO: Perhaps handle other ApiResponse fields as well? 103 | (cond-> {:content content} 104 | description (assoc :description description)))) 105 | 106 | (defn operation->data 107 | "Converts a Java Operation to a map of parameters, responses, schemas and handler 108 | that conforms to reitit." 109 | [^Operation op handlers] 110 | (try 111 | (let [params (into [] (.getParameters op)) 112 | request-body (.getRequestBody op) 113 | params (if (nil? request-body) 114 | params 115 | (conj params request-body)) 116 | schemas (->> params 117 | (map p/transform) 118 | (apply merge-with into) 119 | (wrap-map :path) 120 | (wrap-map :query) 121 | (wrap-map :header) 122 | (wrap-map :cookie)) 123 | responses (-> (.getResponses op) 124 | (update-kvs handle-response-key response->data))] 125 | (cond-> {:handler (get handlers (.getOperationId op))} 126 | (seq schemas) (assoc :parameters schemas) 127 | (seq responses) (assoc :responses responses))) 128 | (catch Exception e 129 | (throw (ex-info (str "Exception processing operation " 130 | (pr-str (.getOperationId op)) 131 | ": " (ex-message e)) 132 | {:operation op} 133 | e))))) 134 | 135 | (defn path-item->data 136 | "Converts a path to its corresponding vector of method and the operation map" 137 | [^PathItem path-item handlers] 138 | (update-kvs (.readOperationsMap path-item) 139 | #(keyword (.toLowerCase (.toString ^PathItem$HttpMethod %))) 140 | #(operation->data % handlers))) 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # navi 2 | 3 | [![Tests](https://github.com/lispyclouds/navi/actions/workflows/ci.yaml/badge.svg)](https://github.com/lispyclouds/navi/actions/workflows/ci.yaml) 4 | [![Clojars Project](https://img.shields.io/clojars/v/org.clojars.lispyclouds/navi.svg)](https://clojars.org/org.clojars.lispyclouds/navi) 5 | 6 | A tiny library converting [OpenAPI](https://www.openapis.org/) route definitions to [Reitit](https://cljdoc.org/jump/release/metosin/reitit) routes. 7 | 8 | Suitable for [spec-first](https://www.atlassian.com/blog/technology/spec-first-api-development) servers. 9 | 10 | ## Features 11 | 12 | - Read OpenAPI 3 definitions as JSON or YAML 13 | - Remote and relative [refs](https://swagger.io/docs/specification/using-ref/) 14 | - Request and response coercions powered by [Malli](https://github.com/metosin/malli) 15 | - requestBody coercion 16 | - Strings with uuid and pattern types 17 | - A large subset of OpenAPI types are currently supported, please raise an issue if something is unsupported 18 | 19 | Currently unsupported (raise an issue if needed!): 20 | 21 | - Other coercion libs 22 | - `oneOf` composed schema (mostly can be handled by `anyOf`) 23 | - `Any Type` can mostly be worked around by omitting it completely 24 | - Some extra string formats 25 | 26 | Any contributions are much much welcome and appreciated! 27 | 28 | ## Installation 29 | 30 | Leiningen/Boot 31 | 32 | ```clojure 33 | [org.clojars.lispyclouds/navi "0.1.5"] 34 | ``` 35 | 36 | Clojure CLI/deps.edn 37 | 38 | ```clojure 39 | {org.clojars.lispyclouds/navi {:mvn/version "0.1.5"}} 40 | ``` 41 | 42 | Gradle 43 | 44 | ```groovy 45 | compile 'org.clojars.lispyclouds:navi:0.1.5' 46 | ``` 47 | 48 | Maven 49 | 50 | ```xml 51 | 52 | org.clojars.lispyclouds 53 | navi 54 | 0.1.5 55 | 56 | ``` 57 | 58 | ## Usage 59 | 60 | Given a `api.yaml`: 61 | 62 | ```yaml 63 | openapi: "3.0.0" 64 | 65 | info: 66 | title: My calculator 67 | version: "1.0" 68 | description: My awesome calc! 69 | 70 | paths: 71 | "/add/{n1}/{n2}": 72 | get: 73 | operationId: AddGet 74 | summary: Adds two numbers 75 | 76 | parameters: 77 | - name: n1 78 | required: true 79 | in: path 80 | description: The first number 81 | schema: 82 | type: integer 83 | - name: n2 84 | required: true 85 | in: path 86 | description: The second number 87 | schema: 88 | type: integer 89 | post: 90 | operationId: AddPost 91 | summary: Adds two numbers via POST 92 | 93 | requestBody: 94 | description: The numebers map 95 | required: true 96 | content: 97 | application/json: 98 | schema: 99 | $ref: "#/components/schemas/NumbersMap" 100 | "/health": 101 | get: 102 | operationId: HealthCheck 103 | summary: Returns Ok if all is well 104 | 105 | components: 106 | schemas: 107 | NumbersMap: 108 | type: object 109 | required: 110 | - n1 111 | - n2 112 | properties: 113 | n1: 114 | type: integer 115 | description: The first number 116 | n2: 117 | type: integer 118 | description: The second number 119 | ``` 120 | 121 | A clojure map of OperationId to handler fns: 122 | 123 | ```clojure 124 | (def handlers 125 | {"AddGet" (fn [{{{:keys [n1 n2]} :path} :parameters}] 126 | {:status 200 127 | :body (str (+ n1 n2))}) 128 | "AddPost" (fn [{{{:keys [n1 n2]} :body} :parameters}] 129 | {:status 200 130 | :body (str (+ n1 n2))}) 131 | "HealthCheck" (fn [_] 132 | {:status 200 133 | :body "Ok"})}) 134 | ``` 135 | 136 | Generate the routes: 137 | 138 | ```clojure 139 | (require '[navi.core :as navi]) 140 | 141 | (navi/routes-from (slurp "api.yaml") handlers) 142 | => 143 | [["/add/{n1}/{n2}" 144 | {:get 145 | {:handler #function[navi.core/fn--8260], 146 | :parameters 147 | {:path 148 | [:map 149 | [:n1 #function[clojure.core/int?]] 150 | [:n2 #function[clojure.core/int?]]]}}, 151 | :post 152 | {:handler #function[navi.core/fn--8266], 153 | :parameters 154 | {:body 155 | [:map 156 | {:closed false} 157 | [:n1 #function[clojure.core/int?]] 158 | [:n2 #function[clojure.core/int?]]]}}}] 159 | ["/health" {:get {:handler #function[navi.core/fn--8271]}}]] 160 | ``` 161 | 162 | Bootstrapping a Jetty server: 163 | 164 | ```clojure 165 | (ns server.main 166 | (:require 167 | [muuntaja.core :as m] 168 | [navi.core :as navi] 169 | [reitit.coercion.malli :as malli] 170 | [reitit.http :as http] 171 | [reitit.http.coercion :as coercion] 172 | [reitit.http.interceptors.exception :as exception] 173 | [reitit.http.interceptors.muuntaja :as muuntaja] 174 | [reitit.http.interceptors.parameters :as parameters] 175 | [reitit.interceptor.sieppari :as sieppari] 176 | [reitit.ring :as ring] 177 | [ring.adapter.jetty :as jetty]) 178 | (:gen-class)) 179 | 180 | (def server 181 | (http/ring-handler 182 | (http/router (-> "api.yaml" 183 | slurp 184 | (navi/routes-from handlers)) ; handlers is the map described before 185 | {:data {:coercion malli/coercion 186 | :muuntaja m/instance 187 | :interceptors [(parameters/parameters-interceptor) 188 | (muuntaja/format-negotiate-interceptor) 189 | (muuntaja/format-response-interceptor) 190 | (exception/exception-interceptor) 191 | (muuntaja/format-request-interceptor) 192 | (coercion/coerce-exceptions-interceptor) 193 | (coercion/coerce-response-interceptor) 194 | (coercion/coerce-request-interceptor)]}}) 195 | (ring/routes 196 | (ring/create-default-handler 197 | {:not-found (constantly {:status 404 198 | :headers {"Content-Type" "application/json"} 199 | :body "{\"message\": \"Took a wrong turn?\"}"})})) 200 | {:executor sieppari/executor})) 201 | 202 | (defn -main 203 | [& _] 204 | (jetty/run-jetty (var server) 205 | {:host "0.0.0.0" 206 | :port 7777 207 | :join? false 208 | :async? true})) 209 | ``` 210 | 211 | ### Build Requirements 212 | 213 | - JDK 8+ 214 | - Clojure [tools.deps](https://clojure.org/guides/getting_started) 215 | 216 | ### Running tests locally 217 | 218 | - `clojure -X:test` to run all tests 219 | 220 | ## License 221 | 222 | Copyright © 2020- Rahul De 223 | 224 | Distributed under the MIT License. See LICENSE. 225 | -------------------------------------------------------------------------------- /test/navi/impl_test.clj: -------------------------------------------------------------------------------- 1 | ; Copyright 2021- Rahul De 2 | ; 3 | ; Use of this source code is governed by an MIT-style 4 | ; license that can be found in the LICENSE file or at 5 | ; https://opensource.org/licenses/MIT. 6 | 7 | (ns navi.impl-test 8 | (:require 9 | [clojure.test :refer [deftest is testing]] 10 | [navi.impl :as i]) 11 | (:import 12 | [clojure.lang ExceptionInfo] 13 | [io.swagger.v3.oas.models Operation PathItem] 14 | [io.swagger.v3.oas.models.media 15 | Content 16 | DateSchema 17 | DateTimeSchema 18 | IntegerSchema 19 | MediaType 20 | ObjectSchema 21 | StringSchema] 22 | [io.swagger.v3.oas.models.parameters HeaderParameter Parameter PathParameter] 23 | [io.swagger.v3.oas.models.responses ApiResponse ApiResponses] 24 | [java.util Map])) 25 | 26 | (deftest map-to-malli-spec 27 | (testing "surrounding values of a clojure map to a malli map spec" 28 | (is (= {:path [:map [:x string?] [:y int?]]} 29 | (i/wrap-map :path {:path [[:x string?] [:y int?]]})))) 30 | (testing "surround ignores non matching key" 31 | (is (= {:query [:map [:x string?]]} 32 | (i/wrap-map :path {:query [:map [:x string?]]}))))) 33 | 34 | (deftest openapi-properties-to-malli-spec 35 | (testing "convert a required OpenAPI Map entry" 36 | (let [property (Map/entry "id" (StringSchema.))] 37 | (is (= [:id string?] 38 | (i/->prop-schema #{"id" "x"} property))))) 39 | (testing "convert an optional OpenAPI Map entry" 40 | (let [property (Map/entry "id" (StringSchema.))] 41 | (is (= [:id {:optional true} string?] 42 | (i/->prop-schema #{"x"} property))))) 43 | 44 | (testing "convert a DateTime OpenAPI Map entry" 45 | (let [property (Map/entry "timestamp" (DateTimeSchema.))] 46 | (is (= [:timestamp inst?] 47 | (i/->prop-schema #{"timestamp"} property))))) 48 | 49 | (testing "convert a Date OpenAPI Map entry" 50 | (let [property (Map/entry "date" (DateSchema.))] 51 | (is (= [:date inst?] 52 | (i/->prop-schema #{"date"} property)))))) 53 | 54 | (deftest openapi-parameters-to-malli-spec 55 | (testing "convert a required OpenAPI Parameter" 56 | (let [param (doto (Parameter.) 57 | (.setName "x") 58 | (.setRequired true) 59 | (.setSchema (StringSchema.)))] 60 | (is (= [:x string?] 61 | (i/->param-schema param))))) 62 | (testing "convert an optional OpenAPI Map entry" 63 | (let [param (doto (Parameter.) 64 | (.setName "x") 65 | (.setSchema (StringSchema.)))] 66 | (is (= [:x {:optional true} string?] 67 | (i/->param-schema param)))))) 68 | 69 | (deftest responses-to-malli-spec 70 | (testing "empty response" 71 | (let [response (ApiResponse.)] 72 | (is (= {:content {:default {:schema nil?}}} 73 | (i/response->data response))))) 74 | (testing "default media type" 75 | (let [media (doto (MediaType.) 76 | (.setSchema (StringSchema.))) 77 | content (doto (Content.) 78 | (.put "default" media)) 79 | response (doto (ApiResponse.) 80 | (.setContent content))] 81 | (is (= {:content {:default {:schema string?}}} 82 | (i/response->data response))))) 83 | (testing "json object response" 84 | (let [media (doto (MediaType.) 85 | (.setSchema (ObjectSchema.))) 86 | content (doto (Content.) 87 | (.put "application/json" media)) 88 | response (doto (ApiResponse.) 89 | (.setContent content))] 90 | (is (= {:content {"application/json" {:schema [:map {:closed false}]}}} 91 | (i/response->data response)))))) 92 | 93 | (deftest openapi-operation-to-malli-spec 94 | (testing "OpenAPI operation to reitit ring handler" 95 | (let [param (doto (PathParameter.) 96 | (.setName "x") 97 | (.setSchema (IntegerSchema.))) 98 | hparam (doto (HeaderParameter.) 99 | (.setName "y") 100 | (.setSchema (StringSchema.))) 101 | response (doto (ApiResponse.) 102 | (.setContent (doto (Content.) 103 | (.put "application/json" 104 | (doto (MediaType.) 105 | (.setSchema (ObjectSchema.))))))) 106 | responses (doto (ApiResponses.) 107 | (.put "200" response)) 108 | operation (doto (Operation.) 109 | (.setParameters [param hparam]) 110 | (.setResponses responses) 111 | (.setOperationId "TestOp")) 112 | handlers {"TestOp" "a handler"}] 113 | (is (= {:handler "a handler" 114 | :parameters {:path [:map [:x int?]] 115 | :header [:map [:y {:optional true} string?]]} 116 | :responses {200 {:content {"application/json" {:schema [:map {:closed false}]}}}}} 117 | (i/operation->data operation handlers)))))) 118 | 119 | (deftest openapi-operation-to-malli-spec-missing-schema 120 | (testing "Missing response schema results in an informative error" 121 | (let [param (doto (PathParameter.) 122 | (.setName "x") 123 | (.setSchema (IntegerSchema.))) 124 | hparam (doto (HeaderParameter.) 125 | (.setName "y") 126 | (.setSchema (StringSchema.))) 127 | response (doto (ApiResponse.) 128 | (.setContent (doto (Content.) 129 | (.put "application/json" (MediaType.))))) 130 | responses (doto (ApiResponses.) 131 | (.put "200" response)) 132 | operation (doto (Operation.) 133 | (.setParameters [param hparam]) 134 | (.setResponses responses) 135 | (.setOperationId "TestOp")) 136 | handlers {"TestOp" "a handler"}] 137 | (is (thrown-with-msg? 138 | ExceptionInfo 139 | #".*TestOp.*schema" 140 | (i/operation->data operation handlers)) 141 | "Error message contains operation name and mentions the missing schema")))) 142 | 143 | (deftest openapi-path-to-malli-spec 144 | (testing "OpenAPI path to reitit route" 145 | (let [param (doto (PathParameter.) 146 | (.setName "x") 147 | (.setSchema (IntegerSchema.))) 148 | operation (doto (Operation.) 149 | (.setParameters [param]) 150 | (.setOperationId "TestOp")) 151 | handlers {"TestOp" "a handler"} 152 | path-item (doto (PathItem.) 153 | (.setGet operation))] 154 | (is (= {:get {:handler "a handler" 155 | :parameters {:path [:map [:x int?]]}}} 156 | (i/path-item->data path-item handlers)))))) -------------------------------------------------------------------------------- /src/navi/transform.clj: -------------------------------------------------------------------------------- 1 | ; Copyright 2021- Rahul De 2 | ; 3 | ; Use of this source code is governed by an MIT-style 4 | ; license that can be found in the LICENSE file or at 5 | ; https://opensource.org/licenses/MIT. 6 | 7 | (ns navi.transform 8 | (:require 9 | [navi.impl :as i] 10 | [navi.protocols :as p]) 11 | (:import 12 | [io.swagger.v3.oas.models.media 13 | ArraySchema 14 | BinarySchema 15 | BooleanSchema 16 | ByteArraySchema 17 | ComposedSchema 18 | IntegerSchema 19 | JsonSchema 20 | MediaType 21 | NumberSchema 22 | ObjectSchema 23 | Schema 24 | StringSchema 25 | DateTimeSchema 26 | DateSchema 27 | UUIDSchema] 28 | [io.swagger.v3.oas.models.parameters 29 | CookieParameter 30 | HeaderParameter 31 | PathParameter 32 | QueryParameter 33 | RequestBody])) 34 | 35 | (defn- empty-schema? [^Schema s] 36 | (or (nil? s) 37 | (and (nil? (.getType s)) 38 | (nil? (.getProperties s)) 39 | (nil? (.getAllOf s)) 40 | (nil? (.getOneOf s)) 41 | (nil? (.getAnyOf s)) 42 | (nil? (.getNot s)) 43 | (nil? (.getAdditionalProperties s)) 44 | (nil? (.getPatternProperties s)) 45 | (nil? (.getItems s)) 46 | (nil? (.get$ref s)) 47 | (let [ext (.getExtensions s)] 48 | (or (nil? ext) 49 | (empty? ext)))))) 50 | 51 | (defn- wrap-and 52 | [conditions] 53 | (if (= 1 (count conditions)) 54 | (first conditions) 55 | (into [:and] conditions))) 56 | 57 | (defn- transform-numeric 58 | [main-pred ^Schema schema] 59 | ;; The swagger-parser library does not 60 | ;; seem to recognize `exclusiveMinimum: true` and 61 | ;; `exclusiveMaximum: true`. 62 | (wrap-and 63 | (into [main-pred] 64 | (remove #(nil? (second %))) 65 | [[:>= (.getMinimum schema)] 66 | [:<= (.getMaximum schema)]]))) 67 | 68 | (defn- transform-object 69 | [^ObjectSchema schema] 70 | (let [required (->> schema 71 | .getRequired 72 | (into #{})) 73 | schemas (->> schema 74 | .getProperties 75 | (map #(i/->prop-schema required %)) 76 | (into []))] 77 | (into [:map {:closed false}] schemas))) 78 | 79 | (defn- transform-array 80 | [^ArraySchema schema] 81 | (let [items (.getItems schema)] 82 | [:sequential 83 | (if (nil? items) 84 | any? 85 | (p/transform items))])) 86 | 87 | (defn- transform-composed 88 | [^ComposedSchema schema] 89 | (let [[schemas compose-as] (cond 90 | (< 0 (count (.getAnyOf schema))) 91 | [(.getAnyOf schema) :or] 92 | 93 | (< 0 (count (.getAllOf schema))) 94 | [(.getAllOf schema) :and] 95 | 96 | :else ;; TODO: Implement oneOf 97 | (throw (IllegalArgumentException. "Unsupported composite schema. Use either anyOf, allOf")))] 98 | (->> schemas 99 | (map p/transform) 100 | (into [compose-as])))) 101 | 102 | (defn- transform-string 103 | "Given a StringSchema or a JsonSchema that we know is string-typed, 104 | return a Malli schema that respects format, length constraints, pattern, and enum." 105 | [^Schema schema] 106 | (let [preds {"uuid" uuid? 107 | "binary" bytes? 108 | "byte" string? 109 | "date" inst? 110 | "date-time" inst? 111 | "password" string? 112 | "email" string? 113 | "uri" uri? 114 | "hostname" string? 115 | "ipv4" string? 116 | "ipv6" string?} 117 | content-fn (get preds (.getFormat schema) string?) 118 | max-length (.getMaxLength schema) 119 | min-length (.getMinLength schema) 120 | properties (cond-> nil 121 | max-length (assoc :max max-length) 122 | min-length (assoc :min min-length)) 123 | pattern (some-> schema .getPattern re-pattern) 124 | enums (into [:enum] (.getEnum schema))] 125 | (cond 126 | (and properties pattern) 127 | [:and content-fn [:string properties] pattern] 128 | 129 | properties 130 | [:and content-fn [:string properties]] 131 | 132 | pattern 133 | [:and content-fn pattern] 134 | 135 | (< 1 (count enums)) 136 | enums 137 | 138 | :else 139 | content-fn))) 140 | 141 | (extend-protocol p/Transformable 142 | StringSchema 143 | (p/transform [schema] 144 | (transform-string schema)) 145 | 146 | DateSchema 147 | (p/transform [_] inst?) 148 | 149 | DateTimeSchema 150 | (p/transform [_] inst?) 151 | 152 | UUIDSchema 153 | (p/transform [_] uuid?) 154 | 155 | IntegerSchema 156 | (p/transform [schema] 157 | (transform-numeric int? schema)) 158 | 159 | NumberSchema 160 | (p/transform [schema] 161 | (transform-numeric number? schema)) 162 | 163 | BooleanSchema 164 | (p/transform [_] boolean?) 165 | 166 | ComposedSchema 167 | (p/transform [schema] 168 | (transform-composed schema)) 169 | 170 | ObjectSchema 171 | (p/transform [schema] 172 | (transform-object schema)) 173 | 174 | ArraySchema 175 | (p/transform [schema] 176 | (transform-array schema)) 177 | 178 | JsonSchema 179 | (p/transform [schema] 180 | (let [pred (fn [typ] 181 | (case typ 182 | "array" (transform-array schema) 183 | "boolean" boolean? 184 | "integer" (transform-numeric int? schema) 185 | "null" nil? 186 | "number" (transform-numeric number? schema) 187 | "object" (transform-object schema) 188 | "string" (transform-string schema) 189 | (throw (IllegalArgumentException. (format "Unsupported type %s for schema %s" typ schema))))) 190 | types (.getTypes schema)] 191 | (case (count types) 192 | 0 (if (empty-schema? schema) 193 | any? 194 | (transform-composed schema)) 195 | 1 (-> types first pred) 196 | (into [:or] (map pred types))))) 197 | 198 | BinarySchema 199 | (p/transform [_] any?) 200 | 201 | ByteArraySchema 202 | (p/transform [_] any?) 203 | 204 | nil 205 | (p/transform [_] any?) 206 | 207 | Schema 208 | (p/transform [schema] 209 | (if-let [t (first (.getTypes schema))] 210 | (if (= "null" t) 211 | nil? 212 | (throw (Exception. (str "Unsupported schema" schema)))) 213 | (throw (Exception. "Missing schema")))) 214 | 215 | ;; TODO: Better. The extra [] is there to help with merge-with into 216 | PathParameter 217 | (p/transform [param] 218 | {:path [(i/->param-schema param)]}) 219 | 220 | HeaderParameter 221 | (p/transform [param] 222 | {:header [(i/->param-schema param)]}) 223 | 224 | QueryParameter 225 | (p/transform [param] 226 | {:query [(i/->param-schema param)]}) 227 | 228 | CookieParameter 229 | (p/transform [param] 230 | {:cookie [(i/->param-schema param)]}) 231 | 232 | ;; TODO: Handle more kinds of request-bodies 233 | RequestBody 234 | (p/transform [param] 235 | (if-let [content (.getContent param)] 236 | (let [[media-type ^MediaType content] (first content) 237 | body-spec (-> content 238 | .getSchema 239 | p/transform) 240 | param-key (case media-type 241 | "application/x-www-form-urlencoded" :form 242 | :body)] 243 | {param-key (if (.getRequired param) 244 | body-spec 245 | [:or nil? body-spec])}) 246 | {}))) 247 | 248 | (comment 249 | (set! *warn-on-reflection* true)) 250 | -------------------------------------------------------------------------------- /test/navi/transform_test.clj: -------------------------------------------------------------------------------- 1 | ; Copyright 2021- Rahul De 2 | ; 3 | ; Use of this source code is governed by an MIT-style 4 | ; license that can be found in the LICENSE file or at 5 | ; https://opensource.org/licenses/MIT. 6 | 7 | (ns navi.transform-test 8 | (:require 9 | [clojure.test :refer [deftest is testing]] 10 | [malli.core :as m] 11 | [navi.protocols :as p] 12 | [navi.transform]) 13 | (:import 14 | [io.swagger.v3.oas.models.media 15 | ArraySchema 16 | BinarySchema 17 | ByteArraySchema 18 | ComposedSchema 19 | Content 20 | DateSchema 21 | DateTimeSchema 22 | IntegerSchema 23 | JsonSchema 24 | MediaType 25 | NumberSchema 26 | ObjectSchema 27 | Schema 28 | StringSchema 29 | UUIDSchema] 30 | [io.swagger.v3.oas.models.parameters 31 | CookieParameter 32 | HeaderParameter 33 | PathParameter 34 | QueryParameter 35 | RequestBody] 36 | [java.util LinkedHashMap])) 37 | 38 | (deftest primitives 39 | (testing "datetime" 40 | (is (= inst? (p/transform (DateTimeSchema.))))) 41 | (testing "date" 42 | (is (= inst? (p/transform (DateSchema.))))) 43 | (testing "string" 44 | (is (= string? (p/transform (StringSchema.))))) 45 | (testing "integer" 46 | (is (= int? (p/transform (IntegerSchema.))))) 47 | (testing "number" 48 | (is (= number? (p/transform (NumberSchema.))))) 49 | (testing "null" 50 | (is (= nil? (p/transform (doto (Schema.) (.addType "null"))))) 51 | (is (= nil? (p/transform (doto (JsonSchema.) (.addType "null")))))) 52 | (testing "empty object" 53 | (is (= [:map {:closed false}] 54 | (p/transform (ObjectSchema.))))) 55 | (testing "object" 56 | (let [props (doto (LinkedHashMap.) 57 | (.put "x" (IntegerSchema.)) 58 | (.put "y" (StringSchema.))) 59 | obj (doto (ObjectSchema.) 60 | (.setRequired ["y" "x"]) 61 | (.setProperties props)) 62 | props-json (doto (LinkedHashMap.) 63 | (.put "x" (IntegerSchema.)) 64 | (.put "y" (StringSchema.))) 65 | obj-json (doto (JsonSchema.) 66 | (.addType "object") 67 | (.setRequired ["y" "x"]) 68 | (.setProperties props-json))] 69 | (is (= [:map {:closed false} [:x int?] [:y string?]] 70 | (p/transform obj))) 71 | (is (= [:map {:closed false} [:x int?] [:y string?]] 72 | (p/transform obj-json))))) 73 | (testing "empty array" 74 | (is (= [:sequential any?] 75 | (p/transform (ArraySchema.))))) 76 | (testing "array" 77 | (let [arr (doto (ArraySchema.) 78 | (.setItems (StringSchema.))) 79 | arr-json (doto (JsonSchema.) 80 | (.addType "array") 81 | (.setItems (StringSchema.)))] 82 | (is (= [:sequential string?] 83 | (p/transform arr))) 84 | (is (= [:sequential string?] 85 | (p/transform arr-json))))) 86 | (testing "byte array" 87 | (is (= any? (p/transform (ByteArraySchema.))))) 88 | (testing "binary" 89 | (is (= any? (p/transform (BinarySchema.))))) 90 | (testing "nil" 91 | (is (= any? (p/transform nil)))) 92 | (testing "empty schema" 93 | (is (= any? (p/transform (JsonSchema.)))))) 94 | 95 | (deftest date-schema-transformations 96 | (testing "DateSchema transforms to inst? predicate" 97 | (let [schema (DateSchema.)] 98 | (is (= inst? (p/transform schema))))) 99 | 100 | (testing "DateTimeSchema transforms to inst? predicate" 101 | (let [schema (DateTimeSchema.)] 102 | (is (= inst? (p/transform schema))))) 103 | 104 | (testing "inst? validates different date types" 105 | (let [schema (DateTimeSchema.) 106 | pred (p/transform schema)] 107 | (testing "java.util.Date" 108 | (is (pred (java.util.Date.)))) 109 | (testing "java.time.Instant" 110 | (is (pred (java.time.Instant/now)))) 111 | (testing "java.time.LocalDateTime converted to Instant" 112 | (is (pred (-> (java.time.LocalDateTime/now) 113 | (.atZone (java.time.ZoneId/systemDefault)) 114 | .toInstant)))) 115 | (testing "java.time.ZonedDateTime converted to Instant" 116 | (is (pred (-> (java.time.ZonedDateTime/now) 117 | .toInstant)))) 118 | (testing "java.time.OffsetDateTime converted to Instant" 119 | (is (pred (-> (java.time.OffsetDateTime/now) 120 | .toInstant)))))) 121 | 122 | (testing "inst? rejects invalid inputs" 123 | (let [schema (DateTimeSchema.) 124 | pred (p/transform schema)] 125 | (is (not (pred "2024-01-01"))) 126 | (is (not (pred nil))) 127 | (is (not (pred 123)))))) 128 | 129 | (deftest string-formats 130 | (testing "uuid" 131 | (is (= uuid? (p/transform (UUIDSchema.))))) 132 | (testing "jsonschemas with multiple types" 133 | (let [strint (-> (JsonSchema.) 134 | (.types #{"string" "integer"}))] 135 | (is (contains? #{[:or string? int?] [:or int? string?]} (p/transform strint))))) 136 | (testing "regex string" 137 | (let [spec (p/transform (doto (StringSchema.) 138 | (.setPattern "^(\\d+)([KMGTPE]i{0,1})$")))] 139 | (is (m/validate spec "1024Ki")) 140 | (is (not (m/validate spec "1024Kib")))) 141 | (testing "minLength and maxLength" 142 | (let [spec (p/transform (doto (StringSchema.) 143 | (.setMinLength (int 3)) 144 | (.setMaxLength (int 8))))] 145 | (is (not (m/validate spec ""))) 146 | (is (not (m/validate spec "1"))) 147 | (is (m/validate spec "123")) 148 | (is (m/validate spec "12345678")) 149 | (is (not (m/validate spec "123456789")))))) 150 | (testing "enums" 151 | (is (= [:enum "foo" "bar" "baz"] 152 | (p/transform (doto (StringSchema.) 153 | (.setEnum ["foo" "bar" "baz"]))))))) 154 | 155 | (deftest parameters-to-malli-spec 156 | (testing "path" 157 | (let [param (doto (PathParameter.) 158 | (.setName "x") 159 | (.setSchema (IntegerSchema.)))] 160 | (is (= {:path [[:x int?]]} 161 | (p/transform param))))) 162 | (testing "query" 163 | (let [param (doto (QueryParameter.) 164 | (.setName "x") 165 | (.setRequired true) 166 | (.setSchema (IntegerSchema.)))] 167 | (is (= {:query [[:x int?]]} 168 | (p/transform param))))) 169 | (testing "header" 170 | (let [param (doto (HeaderParameter.) 171 | (.setName "x") 172 | (.setRequired true) 173 | (.setSchema (IntegerSchema.)))] 174 | (is (= {:header [[:x int?]]} 175 | (p/transform param))))) 176 | (testing "cookie" 177 | (let [param (doto (CookieParameter.) 178 | (.setName "x") 179 | (.setRequired true) 180 | (.setSchema (IntegerSchema.)))] 181 | (is (= {:cookie [[:x int?]]} 182 | (p/transform param))))) 183 | (testing "required request body" 184 | (let [media (doto (MediaType.) 185 | (.setSchema (ObjectSchema.))) 186 | content (doto (Content.) 187 | (.put "application/json" media)) 188 | param (doto (RequestBody.) 189 | (.setRequired true) 190 | (.setContent content))] 191 | (is (= {:body [:map {:closed false}]} 192 | (p/transform param))))) 193 | (testing "optional request body" 194 | (let [media (doto (MediaType.) 195 | (.setSchema (ObjectSchema.))) 196 | content (doto (Content.) 197 | (.put "application/json" media)) 198 | param (doto (RequestBody.) 199 | (.setRequired false) 200 | (.setContent content))] 201 | (is (= {:body [:or nil? [:map {:closed false}]]} 202 | (p/transform param))))) 203 | (testing "implicitly optional request body" 204 | (let [media (doto (MediaType.) 205 | (.setSchema (ObjectSchema.))) 206 | content (doto (Content.) 207 | (.put "application/json" media)) 208 | param (doto (RequestBody.) 209 | (.setContent content))] 210 | (is (= {:body [:or nil? [:map {:closed false}]]} 211 | (p/transform param)))))) 212 | 213 | (deftest composed-schemas 214 | (testing "anyOf" 215 | (is (= [:or string? int?] 216 | (p/transform (doto (ComposedSchema.) 217 | (.setAnyOf [(StringSchema.) (IntegerSchema.)]))))) 218 | (is (= [:or string? int?] 219 | (p/transform (doto (JsonSchema.) 220 | (.setAnyOf [(.types (JsonSchema.) #{"string"}) 221 | (.types (JsonSchema.) #{"integer"})])))))) 222 | (testing "allOf" 223 | (is (= [:and string? int?] 224 | (p/transform (doto (ComposedSchema.) 225 | (.setAllOf [(StringSchema.) (IntegerSchema.)]))))) 226 | (is (= [:and string? int?] 227 | (p/transform (doto (JsonSchema.) 228 | (.setAllOf [(.types (JsonSchema.) #{"string"}) 229 | (.types (JsonSchema.) #{"integer"})]))))))) 230 | --------------------------------------------------------------------------------