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})