Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 43 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,26 +50,27 @@ same way, ensuring that your response handling code is also correct. Examples ar
6. [Handlers validation](#handlers-validation)
7. [Idiomatic parameters](#idiomatic-parameters)
8. [Parameter defaults](#parameter-defaults)
9. [Built-in media types](#built-in-media-types)
10. [Response validation](#response-validation)
11. [Testing with `martian-test`](#testing-with-martian-test)
9. [Route name sources](#route-name-sources)
10. [Built-in media types](#built-in-media-types)
11. [Response validation](#response-validation)
12. [Testing with `martian-test`](#testing-with-martian-test)
- [Generative testing](#generative-testing)
- [Non-generative testing](#non-generative-testing)
12. [Recording and playback with `martian-vcr`](#recording-and-playback-with-martian-vcr)
13. [Custom behaviour](#custom-behaviour)
13. [Recording and playback with `martian-vcr`](#recording-and-playback-with-martian-vcr)
14. [Custom behaviour](#custom-behaviour)
- [Custom interceptors](#custom-interceptors)
- [Global interceptors](#global-interceptors)
- [Per route interceptors](#per-route-interceptors)
- [Custom coercion matcher](#custom-coercion-matcher)
- [Built-in encoders options](#built-in-encoders-options)
- [Custom media types](#custom-media-types)
- [HTTP client-specific options](#http-client-specific-options)
14. [Development mode](#development-mode)
15. [Java](#java)
16. [Caveats](#caveats)
17. [Development](#development)
18. [Issues and features](#issues-and-features)
19. [Acknowledgements](#acknowledgements)
15. [Development mode](#development-mode)
16. [Java](#java)
17. [Caveats](#caveats)
18. [Development](#development)
19. [Issues and features](#issues-and-features)
20. [Acknowledgements](#acknowledgements)

---

Expand Down Expand Up @@ -125,6 +126,7 @@ connecting your UI to data sources.
- Easy to [add support for any other media type](#custom-media-types) or reconfigure encoders for the built-in ones
- Support for integration testing without requiring external HTTP stubs
- Routes are named as idiomatic kebab-case keywords of the endpoint's `operationId` in the OpenAPI/Swagger definition
(default) or [generated from the URL (path) pattern, HTTP method, and definition](#route-name-sources)
- Parameters are aliased to kebab-case keywords so that your code remains [idiomatic](#idiomatic-parameters) and neat
- [Parameter defaults](#parameter-defaults) can be optionally applied
- Simple, data driven behaviour with low coupling using libraries and patterns you already know
Expand Down Expand Up @@ -324,6 +326,36 @@ If you set the `use-defaults?` option to `true`, they can be seen using `explore
;; => {:method :post, :url "https://api.org/pets/", :body {:id 123, :name "Bryson"}}
```

## Route name sources

By default, you need to have an "operationId" property in the OpenAPI/Swagger definition to name a corresponding route
when using `bootstrap-openapi`/`bootstrap-swagger` functions.

The `:route-name-sources` option can be used to generate route names for definitions that don't have an "operationId":

```clojure
(require '[martian.core :as martian]
'[martian.clj-http :as martian-http])

(-> (martian-http/bootstrap-swagger "https://poligon.aidevs.pl/swagger/poligon.json")
(martian/explore))
;; WARN martian.openapi - No route name, ignoring endpoint {:url-pattern :/dane.txt, :method :get}
;; WARN martian.openapi - No route name, ignoring endpoint {:url-pattern :/verify, :method :post}
;; => []

(-> (martian-http/bootstrap-swagger "https://poligon.aidevs.pl/swagger/poligon.json"
{:route-name-sources [:operationId :method+path]})
(martian/explore))
;; => [[:get-dane-txt "Retrieve data file"] [:post-verify "Submit report"]]
```

The `:route-name-sources` is a vector of route name sources to try in order. Supported sources are:
- `:operationId` — use an "operationId" property from the definition
- `:method+path` — use a method and URL (path) pattern concatenation
- any ternary fn of URL (path) pattern, HTTP method, and definition

Note that [pedestal-api](https://github.com/oliyh/pedestal-api) auto-generates `operationId`s from given route names.

## Built-in media types

These [media types](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types) are available out of the box
Expand Down Expand Up @@ -730,8 +762,6 @@ martian.urlFor("get-pet", new HashMap<String, Object> {{ put("id", 123); }});

## Caveats

- You need `:operationId` in the OpenAPI/Swagger spec to name routes when using `bootstrap-openapi`
- [pedestal-api](https://github.com/oliyh/pedestal-api) automatically generates these from the route name
- Martian does not yet cover every intricacy of JSON schema when parsing OpenAPI/Swagger specs, and as such it may not
transmit data that it decides does not conform to the schema it has derived
- The main examples currently are `anyOf`, `allOf` and `oneOf`
Expand Down
1 change: 1 addition & 0 deletions core/project.clj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
:dependencies [[camel-snake-kebab "0.4.3"]
[clj-commons/clj-yaml "1.0.29"]
[frankiesardo/tripod "0.2.0"]
[inflections "0.15.0"]
[lambdaisland/uri "1.19.155"]
[org.clojure/tools.logging "1.3.0"]
[org.flatland/ordered "1.15.12"]
Expand Down
23 changes: 18 additions & 5 deletions core/src/martian/core.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,20 @@
- `:coercion-matcher` — a unary fn of schema used for parameters coercion;
defaults to the `default-coercion-matcher`;
- `:use-defaults?` — if true, will read 'default' directives from the
OpenAPI/Swagger spec; false by default."
OpenAPI/Swagger spec; false by default;
- `:route-name-sources` — a vector of route name sources; supported sources:
- `:operationId` — use an \"operationId\" property
- `:method+path` — use method + URL (path) pattern
- any fn of 'url-pattern', 'method', 'definition';
defaults to `[:operationId]`."
[api-root json & [opts]]
(let [{:keys [interceptors] :or {interceptors default-interceptors} :as opts} (keywordize-keys opts)
(let [{:keys [interceptors route-name-sources] :as opts} (keywordize-keys opts)
handlers (if (openapi-schema? json)
(openapi->handlers json (interceptors/supported-content-types interceptors))
(swagger->handlers json))]
(let [content-types (-> interceptors
(or default-interceptors)
(interceptors/supported-content-types))]
(openapi->handlers json content-types route-name-sources))
(swagger->handlers json route-name-sources))]
(build-instance api-root handlers opts)))

(def
Expand All @@ -231,7 +239,12 @@
- `:coercion-matcher` — a unary fn of schema used for parameters coercion;
defaults to the `default-coercion-matcher`;
- `:use-defaults?` — if true, will read 'default' directives from the
OpenAPI/Swagger spec; false by default."
OpenAPI/Swagger spec; false by default;
- `:route-name-sources` — a vector of route name sources; supported sources:
- `:operationId` — use an \"operationId\" property
- `:method+path` — use method + URL (path) pattern
- any fn of 'url-pattern', 'method', 'definition';
defaults to `[:operationId]`."
:arglists '([api-root json & [opts]])}
bootstrap-swagger
bootstrap-openapi)
Expand Down
131 changes: 83 additions & 48 deletions core/src/martian/openapi.cljc
Original file line number Diff line number Diff line change
@@ -1,25 +1,26 @@
(ns martian.openapi
(:require [camel-snake-kebab.core :refer [->kebab-case-keyword]]
[lambdaisland.uri :as uri]
[clojure.string :as string]
[clojure.string :as str]
[clojure.walk :refer [keywordize-keys]]
[schema.core :as s]
[inflections.core :refer [parameterize singular]]
[lambdaisland.uri :as uri]
[martian.log :as log]
[martian.schema :refer [leaf-schema wrap-default]]
[martian.utils :as utils]))
[martian.schema :as schema]
[martian.utils :as utils]
[schema.core :as s]))

(defn openapi-schema? [json]
(some #(get json %) [:openapi "openapi"]))
(boolean (some #(get json %) [:openapi "openapi"])))

(defn base-url [url server-url json]
(let [first-server (get-in json [:servers 0 :url] "")
{:keys [scheme host port]} (uri/uri url)
api-root (or server-url first-server)]
(if (and (openapi-schema? json) (not (string/starts-with? api-root "/")))
(if (and (openapi-schema? json) (not (str/starts-with? api-root "/")))
api-root
(str scheme "://"
host
(when (not (string/blank? port)) (str ":" port))
(when (not (str/blank? port)) (str ":" port))
(if (openapi-schema? json)
api-root
(get json :basePath ""))))))
Expand All @@ -33,7 +34,7 @@
(reduce (fn [schema f]
(f property schema))
schema
[wrap-default wrap-nullable]))
[schema/wrap-default wrap-nullable]))

(defn- openapi->schema
([schema components] (openapi->schema schema components #{}))
Expand All @@ -42,7 +43,7 @@
(if (contains? seen-set reference)
s/Any ; If we've already seen this, then we're in a loop. Rather than
; trying to solve for the fixpoint, just return Any.
(recur (martian.schema/lookup-ref reference {:components components})
(recur (schema/lookup-ref reference {:components components})
components
(conj seen-set reference)))
(wrap schema
Expand Down Expand Up @@ -75,7 +76,7 @@
(openapi->schema v components seen-set)}))
(:properties schema))
{s/Any s/Any}))
(leaf-schema schema))))))
(schema/leaf-schema schema))))))

(defn- warn-on-no-matching-content-type
[supported-content-types content header-name]
Expand Down Expand Up @@ -117,48 +118,82 @@
[json-schema content-type] (get-matching-schema value content-types "Content-Type")]]
{:status (if (= status-code "default")
s/Any
(s/eq (if (number? status-code) status-code (utils/string->int (name status-code)))))
(s/eq (if (number? status-code) status-code (parse-long (name status-code)))))
:body (and json-schema (openapi->schema json-schema components))
:content-type content-type}))

(defn- sanitise [x]
(if (string? x)
x
;; consistent across clj and cljs
(-> (str x)
(string/replace-first ":" ""))))
(defn- sanitise-url [url-pattern]
(if (string? url-pattern)
url-pattern
;; NB: This is consistent across CLJ and CLJS.
(str/replace-first (str url-pattern) ":" "")))

(defn tokenise-path [url-pattern]
(let [url-pattern (sanitise url-pattern)
parts (map first (re-seq #"([^{}]+|\{.+?\})" url-pattern))]
(let [sanitised (sanitise-url url-pattern)
url-parts (map first (re-seq #"([^{}]+|\{.+?\})" sanitised))]
(map #(if-let [param-name (second (re-matches #"^\{(.*)\}" %))]
(keyword param-name)
%) parts)))

;; TODO: Substitute with `update-vals` (built-in, cross-platform).
(defn update-vals-future
"An implementation of `update-vals` that is in Clojure 1.11.0+."
[m f]
(zipmap (keys m) (map f (vals m))))

(defn openapi->handlers [openapi-json content-types]
(let [openapi-spec (keywordize-keys openapi-json)
resolve-ref (martian.schema/resolve-ref-fn openapi-spec)
components (:components openapi-spec)]
(for [[url methods] (:paths openapi-spec)
:let [common-parameters (map resolve-ref (:parameters methods))]
[method definition] (dissoc methods :parameters)
;; We only care about things which have a defined operationId, and
;; which aren't the associated OPTIONS call.
:when (and (:operationId definition)
(not= :options method))
:let [parameters (group-by (comp keyword :in) (concat common-parameters
(map resolve-ref (:parameters definition))))
body (process-body (:requestBody definition) components (:encodes content-types))
responses (process-responses (update-vals-future (:responses definition)
resolve-ref)
components (:decodes content-types))]]
(-> {:path-parts (vec (tokenise-path url))
%)
url-parts)))

(defn generate-route-name
[url-pattern method]
;; NB: This is a simple algo based on the naming conventions:
;; - GET "/users/{uid}/orders/" -> :get-user-orders
;; - GET "/users/{uid}/orders/{oid}" -> :get-user-order
(->> (tokenise-path url-pattern)
(partition-all 2)
(map (fn [[part param]]
(cond-> (parameterize (str/replace part "/" ""))
param (singular))))
(cons (name method))
(str/join "-")))

(defn produce-route-name
[route-name-sources url-pattern method definition]
(loop [sources (or route-name-sources [:operationId])]
(let [[source & rest] sources
route-name (cond
(= :operationId source) (:operationId definition)
(= :method+path source) (generate-route-name url-pattern method)
(fn? source) (source url-pattern method definition))]
(if (some? route-name)
(->kebab-case-keyword route-name)
(if (empty? rest)
(log/warn "No route name, ignoring endpoint" {:url-pattern url-pattern :method method})
(recur rest))))))

(defn unique-route-name?
[route-name route-names]
(if (contains? @route-names route-name)
(log/warn "Non-unique route name, ignoring endpoint" {:route-name route-name})
(do (swap! route-names conj route-name) true)))

(defn openapi->handlers
([openapi-json content-types]
(openapi->handlers openapi-json content-types nil))
([openapi-json {:keys [encodes decodes] :as _content-types} route-name-sources]
(let [openapi-spec (keywordize-keys openapi-json)
resolve-ref (schema/resolve-ref-fn openapi-spec)
components (:components openapi-spec)
route-names (atom #{})]
(for [[url-pattern methods] (:paths openapi-spec)
:let [common-parameters (map resolve-ref (:parameters methods))]
[method definition] (dissoc methods :parameters)
:let [route-name (produce-route-name route-name-sources url-pattern method definition)]
;; NB: We only care about routes that have a unique name
;; and which aren't the associated HTTP OPTIONS call.
:when (and (some? route-name)
(unique-route-name? route-name route-names)
(not= :options method))
:let [parameters (->> (map resolve-ref (:parameters definition))
(concat common-parameters)
(group-by (comp keyword :in)))
body (process-body (:requestBody definition) components encodes)
responses (-> (:responses definition)
(update-vals resolve-ref)
(process-responses components decodes))]]
(-> {:path-parts (vec (tokenise-path url-pattern))
:method method
:path-schema (process-parameters (:path parameters) components)
:query-schema (process-parameters (:query parameters) components)
Expand All @@ -172,5 +207,5 @@
:summary (:summary definition)
:description (:description definition)
:openapi-definition definition
:route-name (->kebab-case-keyword (:operationId definition))}
(cond-> (:deprecated definition) (assoc :deprecated? true))))))
:route-name route-name}
(cond-> (:deprecated definition) (assoc :deprecated? true)))))))
Loading