diff --git a/backend/deps.edn b/backend/deps.edn
index fcc2e23b14..45f6cbb6df 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 0000000000..b9b14a889c
--- /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 0000000000..90a0e4f925
--- /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 0000000000..f319a46925
--- /dev/null
+++ b/backend/resources/api-doc.tmpl
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+ Builtin API Documentation - Penpot
+
+
+
+
+
+
+
+ Penpot API Documentation
+
+
+
+ RPC QUERY METHODS:
+
+ {% for item in query-methods %}
+ -
+
+ {#
{{item.type}}
#}
+
{{item.name}}
+
+
+ Auth:
+ {% if item.auth %}YES{% else %}NO{% endif %}
+
+
+
+
+ {% if item.docs %}
+
DOCSTRING:
+
{{item.docs}}
+ {% endif %}
+
+
SPEC EXPLAIN:
+
{{item.spec}}
+
+
+ {% endfor %}
+
+
+ RPC MUTATION METHODS:
+
+ {% for item in mutation-methods %}
+ -
+
+ {#
{{item.type}}
#}
+
{{item.name}}
+
+
+ Auth:
+ {% if item.auth %}YES{% else %}NO{% endif %}
+
+
+
+
+ {% if item.docs %}
+
DOCSTRING:
+
{{item.docs}}
+ {% endif %}
+
+
SPEC EXPLAIN:
+
{{item.spec}}
+
+
+ {% endfor %}
+
+
+
+
+
+
diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj
index 102fd6ab2c..0dc98852b1 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 0000000000..13a6075cce
--- /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 16fa2d1ada..724b527aa6 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 0e366957fd..6079ecf7de 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 c6974472ea..7caff964b4 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 1621ad7606..9faa8adcb5 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 c3f52bac2b..acc1d2c5c6 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})