Skip to content

Commit

Permalink
Swagger Integration (#240)
Browse files Browse the repository at this point in the history
* Implementing the helpers of Swagger Data generation.

* Implementing the core functionality of swagger data generetion.

* Writing base tests for swagger data generation.

* Clearing the test file from dev expressions.

* Fix on json sym -> keyw

* Add Swagger config keys.

* Update and re-factor Swagger implementation.

* Fix styling with a new-line.

* Improve functionality with arbitrary compilation map of routes.

* Kondo fixes.

* Fix on Styling.

* Fix over render? flag.

* Removing the logic from it since it's not the right place now.

* Multiple fixes and changes. Track on PR.

* Fix on tests.

The var itself moved up.

* Providing new swagger/ui values on config.

* A new dependency, hiccup added.

* Re-factore the Swagger/UI implementation on Xiana;

* Providing swagger-ui as a config generated content, implicitly.
* Providing swagger/ui end-points implicitly.
* New API over Swagger functionality.

* Removing left-over require.

* Formatted internally.

* Update swagger.clj

Removing unwanted, development s-expressions from the PR.

* Changes from re-factoring session with Core team.

* Fixing the re-factored function usage.

* Fix bugs WIP

* Refactor nonsense code

* Clean ->swagger-data function

* Clean routes->swagger-data function

* Refactor routes->swagger-data

* Add new docstrings

* Fix clj-kondo

* Move swagger tests to proper namespace

* Fix code to pass tests

* Remove unused namespace

* Change functions name and refactor code

* Cleanup swagger.json response function

* cleanup names

* Fix swagger

* Apply cljstyle to tests

* Apply cljstyle to route_test

* Add missing reitit.swagger namespace

* Update Readme

Co-authored-by: Gustavo Valente <gustavo.valente@flexiana.com>

---------

Co-authored-by: Gustavo Valente <gustavomsvalente@gmail.com>
Co-authored-by: iain <iain@soulflyer.co.uk>
Co-authored-by: Gustavo Valente <gustavo.valente@flexiana.com>
  • Loading branch information
4 people authored Oct 24, 2023
1 parent a8edb93 commit 080d0e2
Show file tree
Hide file tree
Showing 7 changed files with 350 additions and 2 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ Xiana has its own Leiningen template, so you can create a skeleton project with
```shell
lein new xiana app
```

It also has a deps.edn template. Instructions for using it are [here](https://github.com/Flexiana/templates)

[getting-started](./doc/getting-started.md) explains how to use this to create a very simple app with a db, a backend offering an API, and a frontend that displays something from the database.

### As a dependency
Expand Down
10 changes: 10 additions & 0 deletions config/dev/config.edn
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@

:xiana/web-server {:port 3000
:join? false}
:xiana/swagger {:uri-path "/swagger/swagger.json"
:path :swagger.json
:data {:coercion (reitit.coercion.malli/create
{:error-keys #{:coercion :in :schema :value :errors :humanized}
:compile malli.util/closed-schema
:strip-extra-keys true
:default-values true
:options nil})
:middleware [reitit.swagger/swagger-feature]}}
:xiana/swagger-ui {:uri-path "/swagger/swagger-ui"}
:xiana/migration {:store :database
:migration-dir "resources/migrations"
:init-in-transaction? false
Expand Down
4 changes: 3 additions & 1 deletion deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
yogthos/config {:mvn/version "1.2.0"}
hikari-cp/hikari-cp {:mvn/version "3.0.1"}
org.slf4j/slf4j-simple {:mvn/version "2.0.7"}
camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}}
camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}
ring/ring {:mvn/version "1.9.6"}
hiccup/hiccup {:mvn/version "1.0.5"}}

:aliases
{:dev
Expand Down
2 changes: 1 addition & 1 deletion src/xiana/route/helpers.clj
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
(ns xiana.route.helpers
"The default not found, unauthorized and action functions")
"The default not found and action functions")

(defn not-found
"Default not-found response handler helper."
Expand Down
255 changes: 255 additions & 0 deletions src/xiana/swagger.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
(ns xiana.swagger
(:require
[clojure.string :as str]
[hiccup.core :as h]
[jsonista.core :as json]
[malli.util]
[meta-merge.core :refer [meta-merge]]
[reitit.coercion :as coercion]
[reitit.coercion.malli]
[reitit.core :as r]
[reitit.ring :as ring]
[reitit.swagger]
[reitit.trie :as trie]
[ring.util.response]))

(def all-methods
[:get :patch :trace :connect :delete :head :post :options :put])

(defn xiana-route->reitit-route
"xiana-route->reitit-route is taking route entry of our custom shape of routes
and transforms it into proper reitit route entry that is valid on the Swagger
implemention of reitit.
(xiana-route->reitit-route [\"/swagger-ui\" {:action :swagger-ui
:some-values true}])
;; => [\"/swagger-ui\"
{:get
{:handler #function[clojure.core/identity], :action :swagger-ui},
:patch
{:handler #function[clojure.core/identity], :action :swagger-ui},
:trace
{:handler #function[clojure.core/identity], :action :swagger-ui},
:connect
{:handler #function[clojure.core/identity], :action :swagger-ui},
:delete
{:handler #function[clojure.core/identity], :action :swagger-ui},
:head
{:handler #function[clojure.core/identity], :action :swagger-ui},
:post
{:handler #function[clojure.core/identity], :action :swagger-ui},
:action :swagger-ui,
:options
{:handler #function[clojure.core/identity], :action :swagger-ui},
:put
{:handler #function[clojure.core/identity], :action :swagger-ui},
:some-values true}]
"
[[url opt-map & nested-routes :as route] all-methods]
(let [new-opt-map (if (:action opt-map)
(let [action' (:action opt-map)
swagger-base-of-endpoint (:swagger-* opt-map)]
(reduce (fn [acc method]
(-> acc
(assoc-in [method :handler] identity)
(assoc-in [method :action] action')
(merge swagger-base-of-endpoint)))
opt-map
all-methods))
(let [swagger-base-of-endpoint (get opt-map :swagger-* {})]
(reduce (fn [acc method]
(if (get acc method)
(if (get-in acc [method :handler])
acc
(-> acc
(assoc-in [method :handler] identity)
(merge swagger-base-of-endpoint)))
acc))
opt-map
all-methods)))]
(if (-> route meta :no-doc)
nil
(apply conj [url new-opt-map]
(map #(xiana-route->reitit-route % all-methods) nested-routes)))))

(defn xiana-routes->reitit-routes
"Transforms routes to the proper reitit form."
[routes all-methods]
(vec
(keep #(xiana-route->reitit-route % all-methods) routes)))

(defn strip-top-level-keys
[m]
(dissoc m :id :info :host :basePath :definitions :securityDefinitions))

(def base-swagger-spec {:responses ^:displace {:default {:description ""}}})

(defn transform-endpoint
[[method {{:keys [coercion no-doc swagger] :as data} :data
middleware :middleware
interceptors :interceptors}]]
(when (and data (not no-doc))
[method
(meta-merge
base-swagger-spec
(apply meta-merge (keep (comp :swagger :data) middleware))
(apply meta-merge (keep (comp :swagger :data) interceptors))
(when coercion
(coercion/get-apidocs coercion :swagger data))
(select-keys data [:tags :summary :description])
(strip-top-level-keys swagger))]))

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

(defn transform-path
"Transform a path of a compiled route to swagger format."
[[path _ api-verb-map] router]
(when-let [endpoint (some->> api-verb-map (keep transform-endpoint) (seq) (into {}))]
[(swagger-path path (r/options router)) endpoint]))

(defn routes->swagger-map
"Creates the json representation of the routes "
[routes & {route-opt-map :route-opt-map}]
(let [router (ring/router routes (or route-opt-map {}))
swagger {:swagger "2.0"
:x-id ::default}
map-in-order #(->> % (apply concat) (apply array-map))
paths (->> router
(r/compiled-routes)
(map #(transform-path % router))
map-in-order)]
(meta-merge swagger {:paths paths})))

#_(-> (config/config {:framework-edn-config "config/dev/config.edn"})
->default-internal-swagger-ui-html)

(defn ->default-internal-swagger-ui-html
"Generate the html for swagger UI"
[config]
(let [schema-protocol (get-in config [:deps :xiana/web-server :protocol] :http)
swagger-json-uri-path (get-in config [:deps :xiana/swagger :uri-path])]
(h/html [:html {:lang "en"}
[:head
[:meta {:charset "UTF-8"}]
[:title "Swagger UI"]
[:link
{:referrerpolicy "no-referrer",
:crossorigin "anonymous",
:integrity
"sha512-lfbw/3iTOqI2s3gVb0fIwex5Y9WpcFM8Oq6XMpD8R5jMjOgzIgXjDNg7mNqbWS1I6qqC7sFaaMHXNsnVstkQYQ==",
:href
"https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.52.4/swagger-ui.min.css",
:rel "stylesheet"}]
[:style
"html {box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll;}
*, *:before, *:after { box-sizing: inherit;}
body {margin: 0; background: #fafafa;}"]
[:link
{:sizes "32x32",
:href "./favicon-32x32.png",
:type "image/png",
:rel "icon"}]
[:link
{:sizes "16x16",
:href "./favicon-16x16.png",
:type "image/png",
:rel "icon"}]]
[:body
[:div#swagger-ui]
[:script
{:referrerpolicy "no-referrer",
:crossorigin "anonymous",
:integrity
"sha512-w+D7rGMfhW/r7/lGU7mu92gjvuo4ZQddFOm5iJ0EAQNS7mmhCb10I8GcgrGTr1zJvCYcxj4roHMo66sLNQOgqA==",
:src
"https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.52.4/swagger-ui-bundle.min.js"}]
[:script
{:referrerpolicy "no-referrer",
:crossorigin "anonymous",
:integrity
"sha512-OdiS0y42zD5WmBnJ6H8K3SCYjAjIJQrUOAraBx5PH1QSLtq+KNLy80uQKruXCJTGZKdQ7hhu/AD+WC+wuYUS+w==",
:src
"https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/3.52.4/swagger-ui-standalone-preset.min.js"}]
[:script
(str "window.onload = function ()
{
// TODO: [@LeaveNhA] can be replace-able with in-app configuration and pass it with json-encoding
window.ui = SwaggerUIBundle(
{
url: '" swagger-json-uri-path "',
schemes: ['" (name schema-protocol) "'],
dom_id: '#swagger-ui',
deepLinking: true,
presets: [SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset],
plugins: [SwaggerUIBundle.plugins.DownloadUrl],
layout: 'StandaloneLayout'}
);
};")]]])))

(defn- swagger-ui-endpoint
[config]
(let [{:keys [uri-path]} (get-in config [:xiana/swagger-ui])]
^{:no-doc true}
[uri-path
{:get {:action
(fn [state]
(assoc state
:response
(-> state
->default-internal-swagger-ui-html
ring.util.response/response
(ring.util.response/header "Content-Type" "text/html; charset=utf-8"))))}}]))

(defn swagger-json-endpoint-action
[state]
(assoc state
:response
(-> (str (-> state :deps :swagger.json))
ring.util.response/response
(ring.util.response/header "Content-Type" "application/json; charset=utf-8"))))

(defn- swagger-json-endpoint
[config]
(let [{:keys [uri-path]} (get-in config [:xiana/swagger])]
^{:no-doc true}
[uri-path
{:action swagger-json-endpoint-action}]))

(defn swagger-dot-json
"Create swagger.json for all methods for each endpoint"
[routes & {type :type
route-opt-map :route-opt-map}]
(let [reitit-routes (xiana-routes->reitit-routes routes all-methods)
swagger-map (routes->swagger-map reitit-routes :route-opt-map route-opt-map)]
(cond
(= type :json) (json/write-value-as-string swagger-map)
(= type :edn) swagger-map)))

(defn swagger-config?
"Checks if the config has the required keys for swagger functionality.
Required keys:
* :xiana/swagger
* :xiana/swagger-ui"
[config]
(every? some? ((juxt :xiana/swagger-ui :xiana/swagger) config)))

(defn add-swagger-endpoints
"Takes the config and returns it with the swagger endpoints added"
[config]
(let [type :json
config (update-in config [:xiana/swagger :data] eval)
route-opt-map {:data (get-in config [:xiana/swagger :data])}
config (assoc-in config [:xiana/swagger :data] route-opt-map)]
(if (swagger-config? config)
(let [routes (get config :routes)
swagger-routes (apply conj routes [(swagger-ui-endpoint config) (swagger-json-endpoint config)])
json-routes (swagger-dot-json swagger-routes
:type type
:route-opt-map route-opt-map)]
(-> config
(assoc :swagger.json json-routes)
(assoc :routes swagger-routes)))
config)))
9 changes: 9 additions & 0 deletions test/xiana/route_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,18 @@
"Sample routes structure."
{:routes [["/" {:action :action}]]})

(def sample-routes-with-no-doc
"Sample routes structure with no-documentation meta flag."
{:routes [^{:no-doc true} ["/" {:action :action}]]})

(def sample-routes-with-handler
"Sample routes structure."
{:routes [["/" {:handler :handler}]]})

(def sample-routes-with-handler-and-no-doc
"Sample routes structure with no-documentation meta flag."
{:routes [^{:no-doc true} ["/" {:handler :handler}]]})

(def sample-routes-without-action
"Sample routes structure (without action or handler)."
{:routes [["/" {}]]})
Expand Down Expand Up @@ -89,3 +97,4 @@
expected helpers/not-found]
;; verify if action has the expected value
(is (= action expected))))

69 changes: 69 additions & 0 deletions test/xiana/swagger_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
(ns xiana.swagger-test
(:require
[clojure.test :as t :refer [deftest is testing]]
[xiana.swagger :as sut]))

(def sample-routes
"Sample routes structure."
{:routes [["/" {:action :action}]]})

(def sample-routes-with-handler
"Sample routes structure."
{:routes [["/" {:handler :handler}]]})

(def sample-routes-without-action
"Sample routes structure (without action or handler)."
{:routes [["/" {}]]})

(deftest swagger-route-data-generation
(testing "Swagger Data generation from Routes\n"
(testing "Swagger Data from Empty Route"
(let [generated (-> []
:routes
(sut/swagger-dot-json :type :edn)
:paths)
count-of-generated-routes-data (count generated)]
(is generated)
(is (zero? count-of-generated-routes-data))))

(testing "Swagger Data from Sample Route /w handle\n"
(let [generated-swagger-data (-> sample-routes-with-handler
:routes
(sut/swagger-dot-json :type :edn))]
(testing "One swagger route for one route entry?"
(let [generated-route-count (-> generated-swagger-data
:paths
count)]
(is (= generated-route-count 1))))
(testing "Actions should generate every methods"
(let [index-generated-methods-by-sample (->
generated-swagger-data
:paths
(get "/")
keys
set)]
(is (= index-generated-methods-by-sample
(set sut/all-methods)))))))

(testing "Swagger Data from Sample Route /w action"
(let [generated-swagger-data (-> sample-routes
:routes
(sut/swagger-dot-json :type :edn))]
(testing "One swagger route for one route entry?"
(let [generated-route-count (->
generated-swagger-data
:paths
keys
count)]
(is (= generated-route-count
1))))
(testing "Actions should generate every methods"
(let [index-generated-methods-by-sample (->
generated-swagger-data
:paths
(get "/")
keys
set)]
(is (=
index-generated-methods-by-sample
(set sut/all-methods)))))))))

0 comments on commit 080d0e2

Please sign in to comment.