Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for OpenAPI3 #563

Merged
merged 2 commits into from
Mar 15, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 2 additions & 0 deletions modules/reitit-core/src/reitit/coercion.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@
:path :path
:multipart :formData}]
(case specification
:openapi (-get-apidocs coercion specification data)
:swagger (->> (update
data
:parameters
Expand All @@ -156,6 +157,7 @@
(into {}))))
(-get-apidocs coercion specification)))))


Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this vertical space compare with neighbouring ones?

;;
;; integration
;;
Expand Down
74 changes: 73 additions & 1 deletion modules/reitit-malli/src/reitit/coercion/malli.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
[malli.edn :as edn]
[malli.error :as me]
[malli.experimental.lite :as l]
[malli.json-schema :as json-schema]
[malli.swagger :as swagger]
[malli.transform :as mt]
[malli.util :as mu]
Expand Down Expand Up @@ -132,6 +133,76 @@
;; malli options
:options nil})

(defn -get-apidocs-openapi
[coercion {:keys [parameters responses content-types] :or {content-types ["application/json"]}} options]
(let [{:keys [body request]} parameters
parameters (dissoc parameters :request :body)
->schema-object (fn [schema opts]
(let [current-opts (merge options opts)]
(json-schema/transform (coercion/-compile-model coercion schema current-opts)
current-opts)))]

(merge
(when (seq parameters)
{:parameters
(->> (for [[in schema] parameters
:let [{:keys [properties required] :as root} (->schema-object schema {:in in :type :parameter})
required? (partial contains? (set required))]
[k schema] properties]
(merge {:in (name in)
:name k
:required (required? k)
:schema schema}
(select-keys root [:description])))
(into []))})
(when body
;; body uses a single schema to describe every :requestBody
;; the schema-object transformer should be able to transform into distinct content-types
{:requestBody {:content (into {}
(map (fn [content-type]
(let [schema (->schema-object body {:in :requestBody
:type :schema
:content-type content-type})]
[content-type {:schema schema}])))
content-types)}})
Comment on lines +161 to +167
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a very minor suggestion for this snippet and below. Feel free to discard.

Suggested change
{:requestBody {:content (into {}
(map (fn [content-type]
(let [schema (->schema-object body {:in :requestBody
:type :schema
:content-type content-type})]
[content-type {:schema schema}])))
content-types)}})
(->> content-types
(map (juxt identity
(comp #(do {:schema %})
(partial ->schema-object body)
#(do {:in :requestBody
:type :schema
:content-type %}))))
(assoc-in {} [:requestBody :content]))

(when request
;; request allow to different :requestBody per content-type
{:requestBody
{:content
(into {}
(map (fn [[content-type requestBody]]
(let [schema (->schema-object requestBody {:in :requestBody
:type :schema
:content-type content-type})]
[content-type {:schema schema}])))
(:content request))}})
(when responses
{:responses
(into {}
(map (fn [[status {:keys [body content]
:as response}]]
(let [content (merge
(when body
(into {}
(map (fn [content-type]
(let [schema (->schema-object body {:in :responses
:type :schema
:content-type content-type})]
[content-type {:schema schema}])))
content-types))
(when content
(into {}
(map (fn [[content-type schema]]
(let [schema (->schema-object schema {:in :responses
:type :schema
:content-type content-type})]
[content-type {:schema schema}])))
content)))]
[status (merge (select-keys response [:description])
(when content
{:content content}))])))
responses)}))))

(defn create
([]
(create nil))
Expand All @@ -145,7 +216,7 @@
(reify coercion/Coercion
(-get-name [_] :malli)
(-get-options [_] opts)
(-get-apidocs [_ specification {:keys [parameters responses]}]
(-get-apidocs [this specification {:keys [parameters responses] :as data}]
(case specification
:swagger (merge
(if parameters
Expand All @@ -167,6 +238,7 @@
(update :schema compile options)
(update :schema swagger/transform {:type :schema}))
$))]))}))
:openapi (-get-apidocs-openapi this data options)
(throw
(ex-info
(str "Can't produce Schema apidocs for " specification)
Expand Down
12 changes: 12 additions & 0 deletions modules/reitit-openapi/project.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
(defproject metosin/reitit-openapi "0.5.18"
:description "Reitit: OpenAPI-support"
:url "https://github.com/metosin/reitit"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:scm {:name "git"
:url "https://github.com/metosin/reitit"
:dir "../.."}
:plugins [[lein-parent "0.3.8"]]
:parent-project {:path "../../project.clj"
:inherit [:deploy-repositories :managed-dependencies]}
:dependencies [[metosin/reitit-core]])
109 changes: 109 additions & 0 deletions modules/reitit-openapi/src/reitit/openapi.cljc
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
(ns reitit.openapi
(:require [clojure.set :as set]
[clojure.spec.alpha :as s]
[clojure.string :as str]
[meta-merge.core :refer [meta-merge]]
[reitit.coercion :as coercion]
[reitit.core :as r]
[reitit.trie :as trie]))

(s/def ::id (s/or :keyword keyword? :set (s/coll-of keyword? :into #{})))
(s/def ::no-doc boolean?)
(s/def ::tags (s/coll-of (s/or :keyword keyword? :string string?) :kind #{}))
(s/def ::summary string?)
(s/def ::description string?)

(s/def ::openapi (s/keys :opt-un [::id]))
(s/def ::spec (s/keys :opt-un [::openapi ::no-doc ::tags ::summary ::description]))

(def openapi-feature
"Feature for handling openapi-documentation for routes.
Works both with Middleware & Interceptors. Does not participate
in actual request processing, just provides specs for the new
documentation keys for the route data. Should be accompanied by a
[[openapi-spec-handler]] to expose the openapi spec.

New route data keys contributing to openapi docs:

| key | description |
| --------------|-------------|
| :openapi | map of any openapi-data. Must have `:id` (keyword or sequence of keywords) to identify the api
| :no-doc | optional boolean to exclude endpoint from api docs
| :summary | optional short string summary of an endpoint
| :description | optional long description of an endpoint. Supports http://spec.commonmark.org/

Also the coercion keys contribute to openapi spec:

| key | description |
| --------------|-------------|
| :parameters | optional input parameters for a route, in a format defined by the coercion
| :responses | optional descriptions of responses, in a format defined by coercion

Example:
Copy link
Contributor

@piotr-yuxuan piotr-yuxuan Sep 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like this thorough, nice docstring. I noticed top-level directories /examples and /doc, do you think it would be worth adding an example and some explanations there? might be just for the sake of completion, hinting at possible current limitations, and showing copy-paste-ready code.


[\"/api\"
{:openapi {:id :my-api}
:middleware [reitit.openapi/openapi-feature]}

[\"/openapi.json\"
{:get {:no-doc true
:openapi {:info {:title \"my-api\"}}
:handler reitit.openapi/openapi-spec-handler}}]

[\"/plus\"
{:get {:openapi {:tags \"math\"}
:summary \"adds numbers together\"
:description \"takes `x` and `y` query-params and adds them together\"
:parameters {:query {:x int?, :y int?}}
:responses {200 {:body {:total pos-int?}}}
:handler (fn [{:keys [parameters]}]
{:status 200
:body (+ (-> parameters :query :x)
(-> parameters :query :y)})}}]]"
{:name ::openapi
:spec ::spec})

(defn- openapi-path [path opts]
(-> path (trie/normalize opts) (str/replace #"\{\*" "{")))

(defn create-openapi-handler
"Create a ring handler to emit openapi spec. Collects all routes from router which have
an intersecting `[:openapi :id]` and which are not marked with `:no-doc` route data."
[]
(fn create-openapi
([{::r/keys [router match] :keys [request-method]}]
(let [{:keys [id] :or {id ::default} :as openapi} (-> match :result request-method :data :openapi)
ids (trie/into-set id)
strip-top-level-keys #(dissoc % :id :info :host :basePath :definitions :securityDefinitions)
strip-endpoint-keys #(dissoc % :id :parameters :responses :summary :description)
openapi (->> (strip-endpoint-keys openapi)
(merge {:openapi "3.1.0"
:x-id ids}))
accept-route (fn [route]
(-> route second :openapi :id (or ::default) (trie/into-set) (set/intersection ids) seq))
;base-openapi-spec {:responses ^:displace {:default {:description ""}}}
transform-endpoint (fn [[method {{:keys [coercion no-doc openapi] :as data} :data
middleware :middleware
interceptors :interceptors}]]
(if (and data (not no-doc))
[method
(meta-merge
#_base-openapi-spec
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Completely worth keeping if it's a helpful breadcrumb for later developer, I can't judge.

(apply meta-merge (keep (comp :openapi :data) middleware))
(apply meta-merge (keep (comp :openapi :data) interceptors))
(if coercion
(coercion/get-apidocs coercion :openapi data))
(select-keys data [:tags :summary :description])
(strip-top-level-keys openapi))]))
transform-path (fn [[p _ c]]
(if-let [endpoint (some->> c (keep transform-endpoint) (seq) (into {}))]
souenzzo marked this conversation as resolved.
Show resolved Hide resolved
[(openapi-path p (r/options router)) endpoint]))
map-in-order #(->> % (apply concat) (apply array-map))
paths (->> router (r/compiled-routes) (filter accept-route) (map transform-path) map-in-order)]
{:status 200
:body (meta-merge openapi {:paths paths})}))
([req res raise]
(try
(res (create-openapi req))
(catch #?(:clj Exception :cljs :default) e
(raise e))))))
28 changes: 28 additions & 0 deletions modules/reitit-schema/src/reitit/coercion/schema.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
[reitit.coercion :as coercion]
[schema-tools.coerce :as stc]
[schema-tools.core :as st]
[schema-tools.openapi.core :as openapi]
[schema-tools.swagger.core :as swagger]
[schema.coerce :as sc]
[schema.core :as s]
Expand Down Expand Up @@ -67,6 +68,33 @@
(if (:schema $)
(update $ :schema #(coercion/-compile-model this % nil))
$))]))})))
:openapi (merge
(when (seq (dissoc parameters :body :request))
(openapi/openapi-spec {::openapi/parameters
(into
(empty parameters)
(for [[k v] (dissoc parameters :body :request)]
[k (coercion/-compile-model this v nil)]))}))
(when (:body parameters)
{:requestBody (openapi/openapi-spec
{::openapi/content {"application/json" (:body parameters)}})})
(when (:request parameters)
{:requestBody (openapi/openapi-spec
{::openapi/content (:content (:request parameters))})})
(when responses
{:responses
(into
(empty responses)
(for [[k response] responses]
[k (merge
(select-keys response [:description])
(when (:body response)
(openapi/openapi-spec
{::openapi/content {"application/json" (coercion/-compile-model this (:body response) nil)}}))
(when (:content response)
(openapi/openapi-spec
{::openapi/content (:content response)})))]))}))
souenzzo marked this conversation as resolved.
Show resolved Hide resolved

(throw
(ex-info
(str "Can't produce Schema apidocs for " specification)
Expand Down
27 changes: 27 additions & 0 deletions modules/reitit-spec/src/reitit/coercion/spec.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
[reitit.coercion :as coercion]
[spec-tools.core :as st #?@(:cljs [:refer [Spec]])]
[spec-tools.data-spec :as ds #?@(:cljs [:refer [Maybe]])]
[spec-tools.openapi.core :as openapi]
[spec-tools.swagger.core :as swagger])
#?(:clj
(:import (spec_tools.core Spec)
Expand Down Expand Up @@ -105,6 +106,32 @@
(if (:schema $)
(update $ :schema #(coercion/-compile-model this % nil))
$))]))})))
:openapi (openapi/openapi-spec
(merge
(when (seq (dissoc parameters :body :request))
{::openapi/parameters
(into (empty parameters)
(for [[k v] (dissoc parameters :body :request)]
[k (coercion/-compile-model this v nil)]))})
(when (:body parameters)
{:requestBody (openapi/openapi-spec
{::openapi/content {"application/json" (coercion/-compile-model this (:body parameters) nil)}})})
(when (:request parameters)
{:requestBody (openapi/openapi-spec
{::openapi/content (coercion/-compile-model this (:content (:request parameters)) nil)})})
(when responses
{:responses
(into
(empty responses)
(for [[k response] responses]
[k (merge
(select-keys response [:description])
(when (:body response)
(openapi/openapi-spec
{::openapi/content {"application/json" (coercion/-compile-model this (:body response) nil)}}))
(when (:content response)
(openapi/openapi-spec
{::openapi/content (coercion/-compile-model this (:content response) nil)})))]))})))
(throw
(ex-info
(str "Can't produce Spec apidocs for " specification)
Expand Down
3 changes: 2 additions & 1 deletion project.clj
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"modules/reitit-ring/src"
"modules/reitit-http/src"
"modules/reitit-middleware/src"
"modules/reitit-openapi/src"
"modules/reitit-interceptors/src"
"modules/reitit-malli/src"
"modules/reitit-spec/src"
Expand All @@ -86,7 +87,7 @@
[metosin/muuntaja "0.6.8"]
[metosin/sieppari "0.0.0-alpha13"]
[metosin/jsonista "0.3.5"]
[metosin/malli "0.8.2"]
[metosin/malli "0.8.9"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do accept that this is most desirable (in some personal projects I exclude malli from reitit and import the latest version), but is it required by this diff? Would it be more appropriate in a dependency-related PR?

[lambdaisland/deep-diff "0.0-47"]
[meta-merge "1.0.0"]
[com.bhauman/spell-spec "0.1.2"]
Expand Down
Loading