From 55784f64b86ae89b3b23177606818a4117c2f6a2 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 19 Oct 2021 15:33:49 +0200 Subject: [PATCH] :tada: Add entrypoint for autogenerated api docs. --- backend/deps.edn | 4 + backend/resources/api-doc.css | 101 +++++++++++++++++++ backend/resources/api-doc.js | 27 +++++ backend/resources/api-doc.tmpl | 80 +++++++++++++++ backend/src/app/http.clj | 3 + backend/src/app/http/doc.clj | 53 ++++++++++ backend/src/app/rpc.clj | 56 +++++----- backend/src/app/rpc/mutations/share_link.clj | 5 + backend/src/app/rpc/queries/files.clj | 1 + backend/src/app/util/services.clj | 35 +++++-- common/src/app/common/flags.cljc | 1 + 11 files changed, 328 insertions(+), 38 deletions(-) create mode 100644 backend/resources/api-doc.css create mode 100644 backend/resources/api-doc.js create mode 100644 backend/resources/api-doc.tmpl create mode 100644 backend/src/app/http/doc.clj diff --git a/backend/deps.edn b/backend/deps.edn index fcc2e23b1..45f6cbb6d 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -48,6 +48,10 @@ io.sentry/sentry {:mvn/version "5.1.2"} + ;; Pretty Print specs + fipp/fipp {:mvn/version "0.6.24"} + pretty-spec/pretty-spec {:mvn/version "0.1.4"} + software.amazon.awssdk/s3 {:mvn/version "2.17.40"}} :paths ["src" "resources"] diff --git a/backend/resources/api-doc.css b/backend/resources/api-doc.css new file mode 100644 index 000000000..b9b14a889 --- /dev/null +++ b/backend/resources/api-doc.css @@ -0,0 +1,101 @@ +* { + font-family: "JetBrains Mono", monospace; + font-size: 12px; +} + +pre { + margin: 0px; +} + +body { + margin: 0px; + padding: 0px; + padding-top: 20px; + padding-bottom: 20px; + display: flex; + justify-content: center; +} + +main { + display: flex; + flex-direction: column; + align-items: center; + min-width: 900px; + width: 900px; +} + +header { + border-bottom: 1px solid #c0c0c0; + display: flex; + justify-content: center; + width: 100%; +} + +.rpc-doc-content { + margin-top: 20px; + width: 100%; + display: flex; + flex-direction: column; + /* border: 1px solid red; */ + padding: 5px; +} + +.rpc-doc-content > h2:not(:first-child) { + margin-top: 30px; +} + + +.rpc-items { + list-style: none; + padding: 0px; + margin: 0px; +} + +.rpc-item { + /* border: 1px solid red; */ + cursor: pointer; + display: flex; + flex-direction: column; +} + +.rpc-item:not(:last-child) { + margin-bottom: 3px; +} + +.rpc-row-info { + cursor: pointer; + display: flex; + background-color: #eeeeee; + padding: 5px 10px; +} + +.rpc-row-info > *:not(:last-child) { + margin-right: 10px; +} + +.rpc-row-info > * { + /* border: 1px solid green; */ +} + +.rpc-row-info > .type { + font-weight: bold; + width: 70px; +} + +.rpc-row-info > .name { + width: 280px; + /* font-weight: bold; */ +} + +.rpc-row-info > .tags > .tag > span:first-child { + font-weight: bold; +} + +.hidden { + display: none; +} + +.rpc-row-detail { + padding: 5px 10px; + padding-bottom: 20px; +} diff --git a/backend/resources/api-doc.js b/backend/resources/api-doc.js new file mode 100644 index 000000000..90a0e4f92 --- /dev/null +++ b/backend/resources/api-doc.js @@ -0,0 +1,27 @@ +(function() { + document.addEventListener("DOMContentLoaded", function(event) { + const rows = document.querySelectorAll(".rpc-row-info"); + + const onRowClick = (event) => { + const target = event.currentTarget; + for (let node of rows) { + if (node !== target) { + node.nextElementSibling.classList.add("hidden"); + } else { + const sibling = target.nextElementSibling; + + if (sibling.classList.contains("hidden")) { + sibling.classList.remove("hidden"); + } else { + sibling.classList.add("hidden"); + } + } + } + }; + + for (let node of rows) { + node.addEventListener("click", onRowClick); + } + + }); +})(); diff --git a/backend/resources/api-doc.tmpl b/backend/resources/api-doc.tmpl new file mode 100644 index 000000000..f319a4692 --- /dev/null +++ b/backend/resources/api-doc.tmpl @@ -0,0 +1,80 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta name="robots" content="noindex,nofollow"> + <meta http-equiv="x-ua-compatible" content="ie=edge" /> + <title>Builtin API Documentation - Penpot</title> + <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono"> + <style> + {% include "api-doc.css" %} + </style> + <script> + {% include "api-doc.js" %} + </script> + </head> + <body> + <main> + <header> + <h1>Penpot API Documentation</h1> + </header> + <section class="rpc-doc-content"> + + <h2>RPC QUERY METHODS:</h2> + <ul class="rpc-items"> + {% for item in query-methods %} + <li class="rpc-item"> + <div class="rpc-row-info"> + {# <div class="type">{{item.type}}</div> #} + <div class="name">{{item.name}}</div> + <div class="tags"> + <span class="tag"> + <span>Auth:</span> + <span>{% if item.auth %}YES{% else %}NO{% endif %}</span> + </span> + </div> + </div> + <div class="rpc-row-detail hidden"> + {% if item.docs %} + <h3>DOCSTRING:</h3> + <p>{{item.docs}}</p> + {% endif %} + + <h3>SPEC EXPLAIN:</h3> + <pre>{{item.spec}}</pre> + </div> + </li> + {% endfor %} + </ul> + + <h2>RPC MUTATION METHODS:</h2> + <ul class="rpc-items"> + {% for item in mutation-methods %} + <li class="rpc-item"> + <div class="rpc-row-info"> + {# <div class="type">{{item.type}}</div> #} + <div class="name">{{item.name}}</div> + <div class="tags"> + <span class="tag"> + <span>Auth:</span> + <span>{% if item.auth %}YES{% else %}NO{% endif %}</span> + </span> + </div> + </div> + <div class="rpc-row-detail hidden"> + {% if item.docs %} + <h3>DOCSTRING:</h3> + <p>{{item.docs}}</p> + {% endif %} + + <h3>SPEC EXPLAIN:</h3> + <pre>{{item.spec}}</pre> + </div> + </li> + {% endfor %} + </ul> + </section> + </main> + </body> +</html> + diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index 102fd6ab2..0dc98852b 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -10,6 +10,7 @@ [app.common.exceptions :as ex] [app.common.logging :as l] [app.common.spec :as us] + [app.http.doc :as doc] [app.http.errors :as errors] [app.http.middleware :as middleware] [app.metrics :as mtx] @@ -151,6 +152,8 @@ [middleware/errors errors/handle] [middleware/cookies]]} + ["/_doc" {:get (doc/handler rpc)}] + ["/feedback" {:middleware [(:middleware session)] :post feedback}] ["/auth/oauth/:provider" {:post (:handler oauth)}] diff --git a/backend/src/app/http/doc.clj b/backend/src/app/http/doc.clj new file mode 100644 index 000000000..13a6075cc --- /dev/null +++ b/backend/src/app/http/doc.clj @@ -0,0 +1,53 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) UXBOX Labs SL + +(ns app.http.doc + "API autogenerated documentation." + (:require + [app.common.data :as d] + [app.config :as cf] + [app.util.services :as sv] + [app.util.template :as tmpl] + [clojure.java.io :as io] + [clojure.spec.alpha :as s] + [pretty-spec.core :as ps])) + +(defn get-spec-str + [k] + (with-out-str + (ps/pprint (s/form k) + {:ns-aliases {"clojure.spec.alpha" "s" + "clojure.core.specs.alpha" "score" + "clojure.core" nil}}))) + +(defn prepare-context + [rpc] + (letfn [(gen-doc [type [name f]] + (let [mdata (meta f)] + ;; (prn name mdata) + {:type (d/name type) + :name (d/name name) + :auth (:auth mdata true) + :docs (::sv/docs mdata) + :spec (get-spec-str (::sv/spec mdata))}))] + {:query-methods + (into [] + (map (partial gen-doc :query)) + (->> rpc :methods :query (sort-by first))) + :mutation-methods + (into [] + (map (partial gen-doc :mutation)) + (->> rpc :methods :mutation (sort-by first)))})) + +(defn handler + [rpc] + (let [context (prepare-context rpc)] + (if (contains? cf/flags :api-doc) + (fn [_] + {:status 200 + :body (-> (io/resource "api-doc.tmpl") + (tmpl/render context))}) + (constantly {:status 404 :body ""})))) diff --git a/backend/src/app/rpc.clj b/backend/src/app/rpc.clj index 16fa2d1ad..724b527aa 100644 --- a/backend/src/app/rpc.clj +++ b/backend/src/app/rpc.clj @@ -97,37 +97,39 @@ auth? (:auth mdata true)] (l/trace :action "register" :name (::sv/name mdata)) - (fn [params] + (with-meta + (fn [params] - ;; Raise authentication error when rpc method requires auth but - ;; no profile-id is found in the request. - (when (and auth? (not (uuid? (:profile-id params)))) - (ex/raise :type :authentication - :code :authentication-required - :hint "authentication required for this endpoint")) + ;; Raise authentication error when rpc method requires auth but + ;; no profile-id is found in the request. + (when (and auth? (not (uuid? (:profile-id params)))) + (ex/raise :type :authentication + :code :authentication-required + :hint "authentication required for this endpoint")) - (let [params' (dissoc params ::request) - params' (us/conform spec params') - result (f cfg params')] + (let [params' (dissoc params ::request) + params' (us/conform spec params') + result (f cfg params')] - ;; When audit log is enabled (default false). - (when (fn? audit) - (let [resultm (meta result) - request (::request params) - profile-id (or (:profile-id params') - (:profile-id result) - (::audit/profile-id resultm)) - props (d/merge params' (::audit/props resultm))] - (audit :cmd :submit - :type (or (::audit/type resultm) - (::type cfg)) - :name (or (::audit/name resultm) - (::sv/name mdata)) - :profile-id profile-id - :ip-addr (audit/parse-client-ip request) - :props props))) + ;; When audit log is enabled (default false). + (when (fn? audit) + (let [resultm (meta result) + request (::request params) + profile-id (or (:profile-id params') + (:profile-id result) + (::audit/profile-id resultm)) + props (d/merge params' (::audit/props resultm))] + (audit :cmd :submit + :type (or (::audit/type resultm) + (::type cfg)) + :name (or (::audit/name resultm) + (::sv/name mdata)) + :profile-id profile-id + :ip-addr (audit/parse-client-ip request) + :props props))) - result)))) + result)) + mdata))) (defn- process-method [cfg vfn] diff --git a/backend/src/app/rpc/mutations/share_link.clj b/backend/src/app/rpc/mutations/share_link.clj index 0e366957f..6079ecf7d 100644 --- a/backend/src/app/rpc/mutations/share_link.clj +++ b/backend/src/app/rpc/mutations/share_link.clj @@ -31,6 +31,11 @@ :opt-un [::pages])) (sv/defmethod ::create-share-link + "Creates a share-link object. + + Share links are resources that allows external users access to + specific files with specific permissions (flags)." + [{:keys [pool] :as cfg} {:keys [profile-id file-id] :as params}] (db/with-atomic [conn pool] (files/check-edition-permissions! conn profile-id file-id) diff --git a/backend/src/app/rpc/queries/files.clj b/backend/src/app/rpc/queries/files.clj index c6974472e..7caff964b 100644 --- a/backend/src/app/rpc/queries/files.clj +++ b/backend/src/app/rpc/queries/files.clj @@ -204,6 +204,7 @@ (s/keys :req-un [::profile-id ::id])) (sv/defmethod ::file + "Retrieve a file by its ID. Only authenticated users." [{:keys [pool] :as cfg} {:keys [profile-id id] :as params}] (db/with-atomic [conn pool] (let [cfg (assoc cfg :conn conn) diff --git a/backend/src/app/util/services.clj b/backend/src/app/util/services.clj index 1621ad760..9faa8adcb 100644 --- a/backend/src/app/util/services.clj +++ b/backend/src/app/util/services.clj @@ -7,21 +7,34 @@ (ns app.util.services "A helpers and macros for define rpc like registry based services." (:refer-clojure :exclude [defmethod]) - (:require [app.common.data :as d])) + (:require + [app.common.data :as d] + [cuerdas.core :as str])) (defmacro defmethod [sname & body] - (let [[mdata args body] (if (map? (first body)) - [(first body) (first (rest body)) (drop 2 body)] - [nil (first body) (rest body)]) - mdata (assoc mdata - ::spec sname - ::name (name sname)) + (let [[docs body] (if (string? (first body)) + [(first body) (rest body)] + [nil body]) + [mdata body] (if (map? (first body)) + [(first body) (rest body)] + [nil body]) - sym (symbol (str "sm$" (name sname)))] - `(do - (def ~sym (fn ~args ~@body)) - (reset-meta! (var ~sym) ~mdata)))) + [args body] (if (vector? (first body)) + [(first body) (rest body)] + [nil body])] + (when-not args + (throw (IllegalArgumentException. "Missing arguments on `defmethod` macro."))) + + (let [mdata (assoc mdata + ::docs (some-> docs str/<<-) + ::spec sname + ::name (name sname)) + + sym (symbol (str "sm$" (name sname)))] + `(do + (def ~sym (fn ~args ~@body)) + (reset-meta! (var ~sym) ~mdata))))) (def nsym-xf (comp diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index c3f52bac2..acc1d2c5c 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -11,6 +11,7 @@ (def default #{:backend-asserts + :api-doc :registration :demo-users})