diff --git a/deps.edn b/deps.edn index 4dce2ee08..847ab28aa 100644 --- a/deps.edn +++ b/deps.edn @@ -9,8 +9,10 @@ camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.2"} cheshire/cheshire {:mvn/version "5.11.0"} + clj-commons/clj-yaml {:mvn/version "1.0.27"} clojure.java-time/clojure.java-time {:mvn/version "1.2.0"} danlentz/clj-uuid {:mvn/version "0.1.9"} + metosin/spec-tools {:mvn/version "0.10.6"} aero/aero {:mvn/version "1.1.6"} selmer/selmer {:mvn/version "1.12.59"} ch.qos.logback/logback-classic {:mvn/version "1.3.14"} diff --git a/src/main/lrsql/admin/interceptors/lrs_management.clj b/src/main/lrsql/admin/interceptors/lrs_management.clj index 76f326ebb..69ab05aae 100644 --- a/src/main/lrsql/admin/interceptors/lrs_management.clj +++ b/src/main/lrsql/admin/interceptors/lrs_management.clj @@ -31,3 +31,14 @@ (assoc ctx :response {:status 200 :body params})))})) + + +#_(def holder (atom nil)) +(def openapi + (interceptor + {:name ::openapi + :enter (fn openapi [ctx] + #_(reset! holder ctx) + (assoc ctx :response {:status 200 + :body "OK"}) + #_(let [{lrs :com.yetanalytics/lrs} ctx]))})) diff --git a/src/main/lrsql/admin/routes.clj b/src/main/lrsql/admin/routes.clj index d6661a30c..f2a76179d 100644 --- a/src/main/lrsql/admin/routes.clj +++ b/src/main/lrsql/admin/routes.clj @@ -10,7 +10,8 @@ [lrsql.admin.interceptors.jwt :as ji] [lrsql.admin.interceptors.status :as si] [lrsql.util.interceptor :as util-i] - [lrsql.util.headers :as h])) + [lrsql.util.headers :as h] + [lrsql.system.openapi :as oa])) (defn- make-common-interceptors [lrs sec-head-opts] @@ -25,83 +26,170 @@ (defn admin-account-routes [common-interceptors jwt-secret jwt-exp jwt-leeway no-val-opts] #{;; Log into an existing account - ["/admin/account/login" :post (conj common-interceptors - ai/validate-params - ai/authenticate-admin - (ai/generate-jwt jwt-secret jwt-exp)) - :route-name :lrsql.admin.account/login] - ;; Create new account - ["/admin/account/create" :post (conj common-interceptors + (oa/annotate-short + ["/admin/account/login" :post (conj common-interceptors ai/validate-params - (ji/validate-jwt - jwt-secret jwt-leeway no-val-opts) - ji/validate-jwt-account - ai/create-admin) - :route-name :lrsql.admin.account/create] + ai/authenticate-admin + (ai/generate-jwt jwt-secret jwt-exp)) + :route-name :lrsql.admin.account/login] + {:description "Log into an existing account" + :requestBody (oa/json-content (oa/owrap {:username {:type :string} + :password {:type :string}})) + :operationId :login + :responses {200 (oa/response "Account ID and JWT" + (oa/owrap {:account-id {:type :string} + :json-web-token {:type :string}})) + 400 oa/error-400 + 401 oa/error-401}}) + ;; Create new account + (oa/annotate-short + ["/admin/account/create" :post (conj common-interceptors + ai/validate-params + (ji/validate-jwt + jwt-secret jwt-leeway no-val-opts) + ji/validate-jwt-account + ai/create-admin) + :route-name :lrsql.admin.account/create] + {:description "Create new account" + :requestBody (oa/json-content (oa/owrap {:username {:type "string"} + :password {:type "string"}})) + :operationId :create-account + :security [{:bearerAuth []}] + :responses {200 (oa/response "ID of new account" + (oa/owrap {:account-id {:type "string"}})) + 400 oa/error-401 + 401 oa/error-401}}) ;; Update account password - ["/admin/account/password" - :put (conj common-interceptors - ai/validate-update-password-params - (ji/validate-jwt jwt-secret jwt-leeway no-val-opts) - ji/validate-jwt-account - ai/update-admin-password)] + (oa/annotate-short + ["/admin/account/password" :put (conj common-interceptors + ai/validate-update-password-params + (ji/validate-jwt jwt-secret jwt-leeway no-val-opts) + ji/validate-jwt-account + ai/update-admin-password)] + {:description "Update account password" + :requestBody (oa/json-content(oa/owrap {:old-password {:type :string} + :new-password {:type :string}})) + :operationId :update-password + :security [{:bearerAuth []}] + :responses {200 (oa/response "ID of updated account" + (oa/owrap {:account-id {:type "string"}})) + 400 oa/error-400 + 401 oa/error-401}}) ;; Get all accounts - ["/admin/account" :get (conj common-interceptors - (ji/validate-jwt - jwt-secret jwt-leeway no-val-opts) - ji/validate-jwt-account - ai/get-accounts) - :route-name :lrsql.admin.account/get] + (oa/annotate-short + ["/admin/account" :get (conj common-interceptors + (ji/validate-jwt + jwt-secret jwt-leeway no-val-opts) + ji/validate-jwt-account + ai/get-accounts) + :route-name :lrsql.admin.account/get] + {:description "Get all accounts" + :operationId :get-admin-accounts + :security [{:bearerAuth []}] + :responses {200 (oa/response "Array of account objects" + (oa/awrap (oa/owrap {:account-id {:type "string"} + :username {:type "string"}}))) + 401 oa/error-401}}) ;; Get my accounts - ["/admin/me" :get (conj common-interceptors - (ji/validate-jwt - jwt-secret jwt-leeway no-val-opts) - ji/validate-jwt-account - ai/me) - :route-name :lrsql.admin.me/get] + (oa/annotate-short + ["/admin/me" :get (conj common-interceptors + (ji/validate-jwt + jwt-secret jwt-leeway no-val-opts) + ji/validate-jwt-account + ai/me) + :route-name :lrsql.admin.me/get] + {:description "Get account of querying account" + :operationId :get-own-account + :security [{:bearerAuth []}] + :responses {200 (oa/response "Account object referring to own account" + (oa/owrap {:account-id {:type "string"} + :username {:type "string"}}) ) + 401 oa/error-401}}) ;; Delete account (and associated credentials) - ["/admin/account" :delete (conj common-interceptors - ai/validate-delete-params - (ji/validate-jwt - jwt-secret jwt-leeway no-val-opts) - ji/validate-jwt-account - ai/delete-admin) - :route-name :lrsql.admin.account/delete]}) + (oa/annotate-short + ["/admin/account" :delete (conj common-interceptors + ai/validate-delete-params + (ji/validate-jwt + jwt-secret jwt-leeway no-val-opts) + ji/validate-jwt-account + ai/delete-admin) + :route-name :lrsql.admin.account/delete] + {:description "Delete account (and associated credentials)" + :requestBody (oa/json-content (oa/owrap {:account-id {:type "string"}})) + :operationId :delete-admin-account + :security [{:bearerAuth []}] + :responses {200 (oa/response "ID of deleted account" + (oa/owrap {:account-id {:type "string"}})) + 400 oa/error-400 + 401 oa/error-401}})}) (defn admin-cred-routes [common-interceptors jwt-secret jwt-leeway no-val-opts] #{;; Create new API key pair w/ scope set - ["/admin/creds" :post (conj common-interceptors - (ci/validate-params {:scopes? true}) + (oa/annotate-short + ["/admin/creds" :post (conj common-interceptors + (ci/validate-params {:scopes? true}) + (ji/validate-jwt + jwt-secret jwt-leeway no-val-opts) + ji/validate-jwt-account + ci/create-api-keys) + :route-name :lrsql.admin.creds/put] + {:description "Create new API key pair w/scope set" + :requestBody (oa/json-content (oa/ref :Scopes)) + :operationId :create-api-keys + :security [{:bearerAuth []}] + :responses {400 oa/error-400 + 401 oa/error-401 + 200 (oa/response "Object containing key, secret key, and array of scopes" + (oa/ref :ScopedKeyPair))}}) + ;; Create or update new keys w/ scope set + (oa/annotate-short + ["/admin/creds" :put (conj common-interceptors + (ci/validate-params {:key-pair? true + :scopes? true}) (ji/validate-jwt jwt-secret jwt-leeway no-val-opts) ji/validate-jwt-account - ci/create-api-keys) - :route-name :lrsql.admin.creds/put] - ;; Create or update new keys w/ scope set - ["/admin/creds" :put (conj common-interceptors - (ci/validate-params {:key-pair? true - :scopes? true}) - (ji/validate-jwt - jwt-secret jwt-leeway no-val-opts) - ji/validate-jwt-account - ci/update-api-keys) - :route-name :lrsql.admin.creds/post] + ci/update-api-keys) + :route-name :lrsql.admin.creds/post] + {:description "Create or update new keys w/scope set" + :requestBody (oa/json-content (oa/ref :ScopedKeyPair)) + :operationId :update-api-keys + :security [{:bearerAuth []}] + :responses {400 oa/error-400 + 401 oa/error-401 + 200 (oa/response "Key, secret key, and scopes of updated account" + (oa/ref :ScopedKeyPair))}}) ;; Get current keys + scopes associated w/ account - ["/admin/creds" :get (conj common-interceptors - (ji/validate-jwt - jwt-secret jwt-leeway no-val-opts) - ji/validate-jwt-account - ci/read-api-keys) - :route-name :lrsql.admin.creds/get] + (oa/annotate-short + ["/admin/creds" :get (conj common-interceptors + (ji/validate-jwt + jwt-secret jwt-leeway no-val-opts) + ji/validate-jwt-account + ci/read-api-keys) + :route-name :lrsql.admin.creds/get] + {:description "Get current keys + scopes associated w/account" + :operationId :get-api-keys + :security [{:bearerAuth []}] + :responses {200 (oa/response "Array of scoped key pairs" + (oa/awrap (oa/ref :ScopedKeyPair))) + 401 oa/error-401}}) ;; Delete API key pair and associated scopes - ["/admin/creds" :delete (conj common-interceptors - (ci/validate-params {:key-pair? true}) - (ji/validate-jwt - jwt-secret jwt-leeway no-val-opts) - ji/validate-jwt-account - ci/delete-api-keys) - :route-name :lrsql.admin.creds/delete]}) + (oa/annotate-short + ["/admin/creds" :delete (conj common-interceptors + (ci/validate-params {:key-pair? true}) + (ji/validate-jwt + jwt-secret jwt-leeway no-val-opts) + ji/validate-jwt-account + ci/delete-api-keys) + :route-name :lrsql.admin.creds/delete] + {:description "Delete API key pair and associated scopes" + :requestBody (oa/json-content (oa/ref :KeyPair)) + :operationId :delete-api-key + :security [{:bearerAuth []}] + :responses {200 (oa/response "Empty body" {}) + 400 oa/error-400 + 401 oa/error-401}})}) (defn admin-status-routes [common-interceptors jwt-secret jwt-leeway no-val-opts] @@ -135,7 +223,11 @@ (ji/validate-jwt jwt-secret jwt-leeway no-val-opts) ji/validate-jwt-account lm/delete-actor) - :route-name :lrsql.lrs-management/delete-actor]}) + :route-name :lrsql.lrs-management/delete-actor] + ["/admin/openapi" :get (conj common-interceptors + (ji/validate-jwt jwt-secret jwt-leeway no-val-opts) + ji/validate-jwt-account + lm/openapi)]}) (defn add-admin-routes "Given a set of routes `routes` for a default LRS implementation, diff --git a/src/main/lrsql/system/openapi.clj b/src/main/lrsql/system/openapi.clj new file mode 100644 index 000000000..07710f704 --- /dev/null +++ b/src/main/lrsql/system/openapi.clj @@ -0,0 +1,425 @@ +(ns lrsql.system.openapi + (:require [cheshire.core :as json] + [clj-yaml.core :as yaml] + [clojure.string :refer [lower-case upper-case]] + [xapi-schema.spec.resources :as xr] + [spec-tools.openapi.core :as openapi] + [clojure.spec.alpha :as s])) + +(defmacro debug [form] + `(let [res# ~form] + (println ~(str form) ": " (str res#)) + res#)) + +(def path-prefix "/xapi") + +(defn ref [kw-or-str] + {"$ref" (str "#/components/schemas/" (name kw-or-str))}) + +(defn owrap [pairs] ;map of form: {key schema} + {:type :object + :properties pairs + :required (mapv name (keys pairs))}) + +(defn awrap [schema] + {:type :array + :items schema}) + +(defn json-content [schema] + {:content {"application/json" {:schema schema}}}) + +(defn response + ([desc] {:description desc}) + ([desc schema] + {:description desc + :content {"application/json" {:schema schema}}})) + +(defn annotate [route data] + (vary-meta route assoc :openapi data)) + +(defn annotate-short [route data] + (let [[path method] route] + (annotate route {path {method data}}))) + +(def components + {:securitySchemes + {:bearerAuth {:type :http + :scheme :bearer + :bearerFormat :JWT}} + :responses + {:error-400 (response "Bad Request" (ref :Error)) + :error-401 (response "Unauthorized" (ref :Error))} + :schemas + {:Account (owrap {:homePage (ref :IRL) + :name {:type :string}}) + :Activity {:type :object + :required [:id] + :properties {:objectType {:type :string :pattern "String"} + :id (ref :IRI) + :definition {:type :object + :properties {:name {} + :description {} + :type {} + :moreinfo {} + :extensions {}}}}} + :Agent ;maybe important + {:allOf [{:type :object + :properties {:name {:type :string} + :objectType {:type :string}} + :required [:mbox]} + (ref :IFI)]} + :Group {:oneOf [{:properties {:objectType {:type :string :pattern "Group"} + :name {:type :string} + :member (awrap (ref :Agent))} + :required [:objectType :member]} + {:allOf [{:properties {:objectType {:type :string :pattern "Group"} + :name {:type :string} + :member (awrap (ref :Agent))} + :required [:objectType]} + (ref :IFI)]}]} + :Actor {:oneOf [(ref :Group) + (ref :Agent)]} + + :Error (owrap {:error {:type :string}}) + + :IFI {:oneOf [(owrap {:mbox (ref :MailToIRI)}) + (owrap {:mbox_sha1sum {:type :string}}) + (owrap {:openid (ref :URI)}) + (owrap {:account (ref :Account)})]} + + :IRI {:type :string :format :iri} + :IRL {:type :string} + :MailToIRI {:type :string :format :email} + :KeyPair (owrap {:api-key {:type "string"} + :secret-key {:type "string"}}) + + :Person {:type :object + :properties {:objectType {:type :string :pattern "Person"} + :name (awrap {:type :string}) + :mbox (awrap (ref :MailToIRI)) + :mbox_sha1sum (awrap {:type :string}) + :openid* (awrap (ref :URI)) + :account* (awrap (ref :Account))} + :required [:objectType]} + :Scopes (owrap {:scopes (awrap {:type "string"})}) + :ScopedKeyPair {:allOf [(ref :KeyPair) + (ref :Scopes)]} + + :statementId {:type :string} + :Statement {:type :object :description "https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#20-statements"} + + :Timestamp {:type :string :format :date-time} + + :StatementResult {:type :object + :required [:statements] + :properties {:statements (awrap (ref :Statement)) + :more (ref :IRL)}} + :URI {:type :string :format :uri} + :UUID {:type :string :format :uuid}}}) + +(def error-400 {"$ref" (str "#/components/responses/error-400")}) +(def error-401 {"$ref" (str "#/components/responses/error-401")}) + +(def lrs-additions + {(format "%s/health" path-prefix) {:get {:operationId :health + :responses {200 {:description "Empty body---a 200 indicates server is alive"}} + :description "Simple heartbeat"}} + (format "%s/about" path-prefix) {:get {:operationId :get-about + :description "About info" + :responses + {200 (merge {:description "Object containing body text and optional etag"} + (json-content {:type :object + :properties {:body {:type :string} + :etag {:type :string}} + :required [:body]}))}}}}) + +(defn sort-map [m] + (apply sorted-map (apply concat (sort m)))) + +(defn- extract-annotation [route] + (-> route meta :openapi)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;lrsql document routes + +;:?#sdf --- sdf is optional +;:r# ---- ref +;:t# ---- {:type "after-t"} +;(a schema) ---- {:type :array :items schema) +; (o k s k s k s) --- {:type "object" :properties [ks] :required [ +; + +(defn cleaned [kw] + (->> kw name rest rest (apply str) keyword)) + +(defn begins-with-?#? [kw] + (= '(\? \#) (take 2 (name kw)))) +(defn to-param + ([kw] + (let [required (not (begins-with-?#? kw))] + {:name (if required kw (cleaned kw)) + :in :query + :required required})) + ([kw schema] + (merge (to-param kw) {:schema schema}))) + +(defn ->params [items] + (when items + (let [split (reduce (fn [acc v] + (if (keyword? v) + (conj acc [v]) + (update acc (dec (count acc)) + conj v))) + [] + items)] + (mapv #(apply to-param %) split)))) + +(defn a [schema] {:type :array :items schema}) +(defn o [& ks] + (let [pairs (partition 2 ks)] + {:type :object + :properties (mapcat (for [[k schema] pairs] + [(if (begins-with-?#? k) + (cleaned k) + k) + schema])) + :required (->> pairs + (map first) + (filter begins-with-?#?) + cleaned)})) + +(defn render-annote [token] + (cond (keyword? token) + (let [[pre rem] (split-at 2 (name token))] + (cond + (= pre '(\r \#)) (ref (->> rem (apply str) keyword)) + (= pre '(\t \#) ) {:type (->> rem (apply str) keyword)} + (= pre '(\a \#) ) (a (render-annote (->> rem (apply str \r \#) keyword))) + :else token)) + :else + token)) + +(defn k2k [m & ks] + (let [pairs (partition 2 ks)] + (reduce (fn [acc [old noo]] + (cond-> acc + (acc old) (assoc noo (acc old)) + true (dissoc old))) + m + pairs))) + +(defn painless-print [item label] (println label ": " item) item) + +(def render-tree (partial clojure.walk/prewalk render-annote)) + +(defn process-route [m {:keys [desc params security opid rbod] :as data}] + #_(println "params: " params) + #_(println "->params: " (->params params)) + (cond-> data + true (k2k :opid :operationId + :desc :description + :params :parameters + :rbod :requestBody) + true render-tree + params (update :parameters ->params) + (not security) (assoc :security [{:bearerAuth []}]) + rbod (update :requestBody json-content))) + +(defn process-path [p m-map] + (reduce (fn [acc [k v]] + (assoc acc k (process-route k v))) + {} m-map)) + +(defn transform-keys [m f] + (reduce (fn [acc k] + (-> acc + (assoc (f k) (acc k)) + (dissoc k))) + m + (keys m))) + +(defn process-paths [p-map] + (let [add-path-prefix (fn [path-map] + (transform-keys path-map #(str path-prefix %))) + process-all (fn [path-map] + (reduce (fn [acc [k v]] + (assoc acc k (process-path k v))) + {} + path-map))] + (-> p-map + add-path-prefix + process-all))) + +(defn ->print [item] + (println item) + item) + +(defn p [ch] + (sort-map + (process-route "" ch))) + +;paths +;state +;activities/state +;activities/profile +;agents/profile + +;/statements +;/agents +;/activities +; + +(def lrs-resources-shorthand + {"/statements" {:put {:params [:statementId :t#string] + :rbod :r#Statement + :responses {204 (response "No content")} + :opid :put-statement + :desc ""} + :post {:params [] + :rbod {:oneOf [:a#statementId :r#statementId]} + :responses {200 (response "Array of Statement id(s) (UUID) in the same order as the corresponding stored Statements." + :a#statementId)} + :opid :post-statement + :desc "Stores a Statement, or a set of Statements."} + :get {:params [:?#statementId :t#string + :?#voidedStatementId :t#string + :?#agent :r#Actor + :?#verb :r#IRI + :?#activity :r#IRI + :?#registration :r#UUID + :?#related_activities :t#boolean + :?#related_agents :t#boolean + :?#since :r#Timestamp + :?#limit :t#integer + :?#format :t#string + :?#attachments :t#boolean + :?#ascending :t#boolean] + + :responses {200 (response "Requested Statement or Results" + {:oneOf [:r#Statement + :r#StatementResult]})} + :opid :get-statement + :desc "https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Communication.md#21-statement-resource"}} + + "/activities/state" {:put {:params [:activityId :r#IRI + :agent :r#Agent + :?#registration :r#UUID + :stateId :t#string] + :rbod :t#object + :responses {204 (response "No content" )} + :opid :put-state + :desc "Stores or changes the document specified by the given stateId that exists in the context of the specified Activity, Agent, and registration (if specified)."} + :post {:params [:activityId :r#IRI + :agent :r#Agent + :?#registration :r#UUID + :stateId :t#string] + :rbod :t#object + :responses {204 (response "No content" )} + :opid :post-state + :desc "Stores or changes the document specified by the given stateId that exists in the context of the specified Activity, Agent, and registration (if specified)."} + :get {:params [:activityId :r#IRI + :agent :r#Agent + :?#registration :r#UUID + :?#stateId :t#string + :?#since :r#Timestamp] + :responses {200 (response "The requested state document, or an array of stateId(s)" + {:oneOf [:t#object + (a :t#string)]})} + :opid :get-state + :desc "Fetches the document specified by the given stateId that exists in the context of the specified Activity, Agent, and registration (if specified), or an array of stateIds."} + :delete {:params [:activityId :r#IRI + :agent :r#Agent + :?#registration :r#UUID + :?#stateId :t#string] + :responses {204 (response "No content" )} + :opid :delete-state + :desc "Deletes all documents associated with the specified Activity, Agent, and registration (if specified), or just the document specified by stateId"}} + "/agents" {:get {:params [:agent :r#Agent] + :responses {200 (response "Return a special, Person Object for a specified Agent. The Person Object is very similar to an Agent Object, but instead of each attribute having a single value, each attribute has an array value, and it is legal to include multiple identifying properties." :r#Person)} + :opid :get-agent + :desc "Gets a specified agent"}} + + "/activities" {:get {:params [:activityId :r#IRI] + :responses {200 (response "The requested Activity object" :r#Activity)} + :opid :get-activity + :desc "Gets the Activity with the specified activityId"}} + + "/agents/profile" {:put {:params [:agent :r#Agent :profileId :t#string] + :rbod :t#object + :responses {204 (response "No content")} + :opid :put-agents-profile + :desc "Stores or changes the specified Profile document in the context of the specified Agent."} + + :post {:params [:agent :r#Agent :profileId :t#string] + :rbod :t#object + :responses {204 (response "No content")} + :opid :post-agents-profile + :desc "Stores or changes the specified Profile document in the context of the specified Agent."} + :get {:params [:agent :r#Agent :?#profileId :t#string :?#since :r#Timestamp] + :responses {200 (response "If profileId is included in the request, the specified document. Otherwise, an array of profileId for the specified Agent.")} + :opid :get-agents-profile + :desc "Fetches the specified Profile document in the context of the specified Agent. The semantics of the request are driven by the \"profileId\" parameter. If it is included, the GET method will act upon a single defined document identified by \"profileId\". Otherwise, GET will return the available ids."} + :delete {:params [:agent :r#Agent :profileId :t#string] + :responses {204 (response "No content")} + :opid :delete-agents-profile + :desc "Deletes the specified Profile document in the context of the specified Agent."}} + + "/activities/profile" {:put {:params [:activityId :r#IRI :profileId :t#string] + :rbod :t#object + :responses {204 (response "No content")} + :opid :put-activity-profile + :desc "Stores or changes the specified Profile document in the context of the specified Activity."} + + :post {:params [:activityId :r#IRI :profileId :t#string] + :rbod :t#object + :responses {204 (response "No content")} + :opid :post-activity-profile + :desc "Stores or changes the specified Profile document in the context of the specified Activity."} + :get {:params [:activityId :r#IRI + :?#profileId :t#string + :?since :r#Timestamp] + :responses {200 (response "The requested Profile document" :t#object)} + :opid :get-activity-profile + :desc "Fetches the specified Profile document in the context of the specified Activity. The semantics of the request are driven by the \"profileId\" parameter. If it is included, the GET method will act upon a single defined document identified by \"profileId\". Otherwise, GET will return the available ids."} + + :delete {:params [:activityId :r#IRI :profileId :t#string] + :responses {204 (response "No content")} + :opid :delete-activity-profile + :desc "Deletes the specified Profile document in the context of the specified Activity."}}}) + + + +; for all document resources: "updated" timestamp in header of response + + +(def lrs-paths + (merge + (process-paths lrs-resources-shorthand) + lrs-additions)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;end adhoc +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;begin stitching up + +(def general-spec + {:openapi "3.0.0" + :info {:title "LRSQL" + :version "0.7.2"} + :externalDocs {:url "https://github.com/yetanalytics/lrsql/blob/main/doc/endpoints.md"} + :components components}) + +(defn extract-route-spec [route-set] + (reduce (fn [acc route] + (merge-with merge acc (extract-annotation route))) + {} + route-set)) + +(defn final-spec [route-set config-specific] + (merge config-specific + general-spec + {:paths (->> (extract-route-spec route-set) + (merge lrs-paths) + (sort-map))})) + +(defn compile-openapi-yaml [route-set config-specific] + (yaml/generate-string (final-spec route-set config-specific))) +(defn compile-openapi-json [route-set config-specific] + (json/generate-string (final-spec route-set config-specific))) diff --git a/src/main/lrsql/system/webserver.clj b/src/main/lrsql/system/webserver.clj index e7cd48211..f6bd70b45 100644 --- a/src/main/lrsql/system/webserver.clj +++ b/src/main/lrsql/system/webserver.clj @@ -10,6 +10,7 @@ [lrsql.init.oidc :as oidc] [lrsql.init.clamav :as clamav] [lrsql.spec.config :as cs] + [lrsql.system.openapi :as openapi] [lrsql.system.util :refer [assert-config redact-config-vars]] [lrsql.util.cert :as cu] [lrsql.util.interceptor :refer [handle-json-parse-exn]])) @@ -151,7 +152,9 @@ server (-> service i/xapi-default-interceptors http/create-server - http/start)] + http/start) + _ (do (spit "dev-resources/openapi.json" (openapi/compile-openapi-json (::http/routes service) {})) + (spit "dev-resources/openapi.yaml" (openapi/compile-openapi-yaml (::http/routes service) {})))] ;; Logging (let [{{ssl-port :ssl-port} ::http/container-options http-port ::http/port