diff --git a/README.md b/README.md index 72969577..0ad4f42f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config/dev/config.edn b/config/dev/config.edn index 519b87b6..f194f422 100644 --- a/config/dev/config.edn +++ b/config/dev/config.edn @@ -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 diff --git a/deps.edn b/deps.edn index 395114a0..89bd129c 100644 --- a/deps.edn +++ b/deps.edn @@ -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 diff --git a/src/xiana/route/helpers.clj b/src/xiana/route/helpers.clj index 7c5375aa..d2a876a2 100644 --- a/src/xiana/route/helpers.clj +++ b/src/xiana/route/helpers.clj @@ -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." diff --git a/src/xiana/swagger.clj b/src/xiana/swagger.clj new file mode 100644 index 00000000..99ccbf6e --- /dev/null +++ b/src/xiana/swagger.clj @@ -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))) diff --git a/test/xiana/route_test.clj b/test/xiana/route_test.clj index 9fdf053e..c168c2d7 100644 --- a/test/xiana/route_test.clj +++ b/test/xiana/route_test.clj @@ -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 [["/" {}]]}) @@ -89,3 +97,4 @@ expected helpers/not-found] ;; verify if action has the expected value (is (= action expected)))) + diff --git a/test/xiana/swagger_test.clj b/test/xiana/swagger_test.clj new file mode 100644 index 00000000..364ab4cc --- /dev/null +++ b/test/xiana/swagger_test.clj @@ -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)))))))))