Skip to content

Commit

Permalink
Add response specs (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
bombaywalla authored Aug 22, 2024
1 parent 7ad64ac commit 8dd2c18
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 29 deletions.
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@ Experimental

- Read OpenAPI 3 definitions as JSON or YAML
- Remote and relative [refs](https://swagger.io/docs/specification/using-ref/)
- Request coercions powered by [Malli](https://github.com/metosin/malli)
- Request and response coercions powered by [Malli](https://github.com/metosin/malli)
- requestBody coercion
- A fair set of OpenAPI types are currently supported, please raise an issue if something is unsupported

Currently unsupported:
- Response coercions
- Other coercion libs

Any contributions are much much welcome and appreciated!
Expand Down
99 changes: 74 additions & 25 deletions src/navi/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
QueryParameter
RequestBody
Parameter]
[io.swagger.v3.oas.models.responses ApiResponse]
[io.swagger.v3.oas.models Operation
PathItem]
PathItem
PathItem$HttpMethod]
[io.swagger.v3.parser OpenAPIV3Parser]
[io.swagger.v3.parser.core.models ParseOptions]))

Expand All @@ -27,6 +29,17 @@
(contains? m k)
(update-in [k] #(into [:map] %))))

(defn update-kvs
"Update a map using `key-fn` and `val-fn`.
Sort of like composing `update-keys` and `update-vals`.
Unlike `update-keys` or `update-vals`, preserve `nil`s."
[m key-fn val-fn]
(when m
(reduce-kv (fn kv-mapper [m k v]
(assoc m (key-fn k) (val-fn v)))
{}
m)))

(defn schema->spec [^Schema schema]
(let [types (.getTypes schema)]
(if (= 1 (count types))
Expand Down Expand Up @@ -72,7 +85,7 @@

(defmulti spec
(fn [^Schema schema]
(first (.getTypes schema))))
(or (first (.getTypes schema)) "null")))

(defmethod spec
"string"
Expand Down Expand Up @@ -102,6 +115,11 @@
[_]
nil?)

(defmethod spec
nil
[_]
nil?)

(defmethod spec
"object"
[^Schema schema]
Expand Down Expand Up @@ -158,8 +176,49 @@
body-spec
[:or nil? body-spec])}))

;;; Handle reponses

(defn handle-response-key
"Reitit seems to want status codes of a response to be integer keys,
rather than keyword keys or string keys (except for `:default`).
So, convert a string to a Long if relevant.
Else if the string is \"default\", then return `:default`, otherwise pass through.
Arguably, all non-integer status codes should be converted to keywords."
[s]
(cond (re-matches #"\d{3}" s) (Long/parseLong s)
(= "default" s) :default
:else s))

(defn media-type->data
"Convert a Java Schema's MediaType to a spec that Reitit will accept."
[^MediaType mt]
{:schema (spec (.getSchema mt))})

(defn handle-media-type-key
"If the media type is \"default\", then return it as a keyword, otherwise pass through."
[s]
(if (= "default" s)
:default
s))

(defn response->data
"Convert an ApiResponse to a response conforming to reitit."
[^ApiResponse response]
(let [orig-content (.getContent response)
;; If no content then use the nil? schema with a default media type.
;; This is a work-around for a current Reitit bug.
;; See https://github.com/metosin/reitit/issues/691
content (if orig-content
(update-kvs orig-content handle-media-type-key media-type->data)
{:default {:schema nil?}})
description (.getDescription response)]
;; TODO: Perhaps handle other ApiResponse fields as well?
(cond-> {:content content}
description (assoc :description description))))

(defn operation->data
"Converts an Operation to map of parameters, schemas and handler conforming to reitit"
"Converts a Java Operation to a map of parameters, responses, schemas and handler
that conforms to reitit."
[^Operation op handlers]
(let [params (into [] (.getParameters op))
request-body (.getRequestBody op)
Expand All @@ -171,39 +230,29 @@
(apply merge-with into)
(wrap-map :path)
(wrap-map :query)
(wrap-map :header))]
(wrap-map :header))
responses (-> (.getResponses op)
(update-kvs handle-response-key response->data)) ]
(cond-> {:handler (get handlers (.getOperationId op))}
(seq schemas)
(assoc :parameters schemas))))
(seq schemas) (assoc :parameters schemas)
(seq responses) (assoc :responses responses))))

(defn path-item->data
"Converts a path to its corresponding vector of method and the operation map"
[^PathItem path-item handlers]
(->> path-item
.readOperationsMap
(map #(vector (-> ^Map$Entry %
.getKey
.toString
.toLowerCase
keyword)
(-> ^Map$Entry %
.getValue
(operation->data handlers))))
(into {})))
(update-kvs (.readOperationsMap path-item)
#(keyword (.toLowerCase (.toString ^PathItem$HttpMethod %)))
#(operation->data % handlers)))

(defn routes-from
"Takes in the OpenAPI JSON/YAML as string and a map of OperationId to handler fns.
Returns the reitit route map with malli schemas"
[^String api-spec handlers]
(let [parse-options (doto (ParseOptions.)
(.setResolveFully true))]
(->> (.readContents (OpenAPIV3Parser.) api-spec nil parse-options)
.getOpenAPI
.getPaths
(mapv #(vector (.getKey ^Map$Entry %)
(-> ^Map$Entry %
.getValue
(path-item->data handlers)))))))
(.setResolveFully true))
contents (.readContents (OpenAPIV3Parser.) api-spec nil parse-options)
paths (.getPaths (.getOpenAPI contents))]
(mapv identity (update-kvs paths identity #(path-item->data % handlers)))))

(comment
(require '[clojure.pprint :as pp])
Expand Down
39 changes: 37 additions & 2 deletions test/navi/core_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
[io.swagger.v3.oas.models Operation PathItem]
[io.swagger.v3.oas.models.media Content StringSchema IntegerSchema JsonSchema
NumberSchema ObjectSchema ArraySchema MediaType UUIDSchema Schema]
[io.swagger.v3.oas.models.parameters Parameter PathParameter HeaderParameter QueryParameter RequestBody]))
[io.swagger.v3.oas.models.parameters Parameter PathParameter HeaderParameter QueryParameter RequestBody]
[io.swagger.v3.oas.models.responses ApiResponses ApiResponse]))

(deftest map-to-malli-spec
(testing "surrounding values of a clojure map to a malli map spec"
Expand Down Expand Up @@ -126,6 +127,31 @@
(is (#{[:or string? int?] [:or int? string?]}
(core/schema->spec strint))))))

(deftest responses-to-malli-spec
(testing "empty response"
(let [response (ApiResponse.)]
(is (= {:content {:default {:schema nil?}}}
(core/response->data response)))))
(testing "default media type"
(let [media (doto (MediaType.)
(.setSchema (StringSchema.)))
content (doto (Content.)
(.put "default" media))
response (doto (ApiResponse.)
(.setContent content)) ]
(is (= {:content {:default {:schema string?}}}
(core/response->data response)))))
(testing "json object response"
(let [media (doto (MediaType.)
(.setSchema (ObjectSchema.)))
content (doto (Content.)
(.put "application/json" media))
response (doto (ApiResponse.)
(.setContent content)) ]
(is (= {:content {"application/json" {:schema [:map {:closed false}]}}}
(core/response->data response)))))
)

(deftest parameters-to-malli-spec
(testing "path"
(let [param (doto (PathParameter.)
Expand Down Expand Up @@ -185,13 +211,22 @@
hparam (doto (HeaderParameter.)
(.setName "y")
(.setSchema (StringSchema.)))
response (doto (ApiResponse.)
(.setContent (doto (Content.)
(.put "application/json"
(doto (MediaType.)
(.setSchema (ObjectSchema.)))))))
responses (doto (ApiResponses.)
(.put "200" response))
operation (doto (Operation.)
(.setParameters [param hparam])
(.setResponses responses)
(.setOperationId "TestOp"))
handlers {"TestOp" "a handler"}]
(is (= {:handler "a handler"
:parameters {:path [:map [:x int?]]
:header [:map [:y {:optional true} string?]]}}
:header [:map [:y {:optional true} string?]]}
:responses {200 {:content {"application/json" {:schema [:map {:closed false}]}}}}}
(core/operation->data operation handlers))))))

(deftest openapi-path-to-malli-spec
Expand Down

0 comments on commit 8dd2c18

Please sign in to comment.