From 10955f17e27943429a10e90360d0404e3f56e27c Mon Sep 17 00:00:00 2001 From: Filip Sajdak Date: Fri, 9 Oct 2026 20:08:14 +0200 Subject: [PATCH] :sparkles: Add get-file-tokens RPC to read a file's design tokens (#12172) Clients that only need a file's design tokens, such as a CI job that syncs them to a git repository after an update-file webhook, had to download the whole file with get-file. get-file-tokens returns the file's tokens as a DTCG document, the same format the token export produces, or nil when the file has none. Active themes and sets come from the file's tokens status, so they reflect what is currently active. The document is returned as a plain map. A bare TokensLib is not a collection, so the response middleware would hand it to ring unencoded. Relates to #7091 Signed-off-by: Filip Sajdak Signed-off-by: Andrey Antukh Co-authored-by: Andrey Antukh --- backend/src/app/rpc/commands/files.clj | 22 +++++++ backend/test/backend_tests/rpc_file_test.clj | 65 ++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 67153861a3..4751284992 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -746,6 +746,28 @@ (select-keys plugin-data [namespace]) (or plugin-data {})))) +;; --- COMMAND QUERY: get-file-tokens + +(def ^:private schema:get-file-tokens + [:map {:title "get-file-tokens"} + [:file-id ::sm/uuid]]) + +(sv/defmethod ::get-file-tokens + "Return the design tokens of a file as a DTCG document, or nil when + the file has none. Active themes and sets come from the file's + tokens status. Cheap alternative to `get-file` when only the tokens + are needed." + {::doc/added "2.20" + ::sm/params schema:get-file-tokens + ::sm/result [:maybe [:map-of :string :any]]} + [cfg {:keys [::rpc/profile-id file-id]}] + (bfc/check-file-exists cfg file-id) + (check-read-permissions! cfg profile-id file-id) + (let [{:keys [data]} (bfc/get-file cfg file-id)] + (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)] + (when-let [tokens-lib (cfo/get-tokens-lib data)] + (ctob/export-dtcg-json tokens-lib (cfo/get-tokens-status data)))))) + ;; --- COMMAND QUERY: get-file-libraries diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 8feb0516fb..305108003c 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2929,3 +2929,68 @@ (t/is (not (nil? (:error out)))) (let [edata (-> out :error ex-data)] (t/is (= :not-found (:type edata)))))) + +(t/deftest get-file-tokens-without-tokens + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + out (th/command! {::th/type :get-file-tokens + ::rpc/profile-id (:id profile) + :file-id (:id file)})] + (t/is (nil? (:error out))) + (t/is (nil? (:result out))))) + +(t/deftest get-file-tokens-with-tokens + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + set-id (uuid/next) + token-id (uuid/next)] + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :changes + [{:type :set-token-set + :id set-id + :attrs {:id set-id :name "brand-core"}} + {:type :set-token + :set-id set-id + :token-id token-id + :attrs {:id token-id + :name "color.primary-base" + :type :color + :value "#ff0000"}}]) + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 1 + :changes + [{:type :set-tokens-status + :theme-ids #{} + :set-ids #{set-id}}]) + + (let [out (th/command! {::th/type :get-file-tokens + ::rpc/profile-id (:id profile) + :file-id (:id file)}) + dtcg (:result out)] + (t/is (nil? (:error out))) + (t/is (= "#ff0000" (get-in dtcg ["brand-core" "color" "primary-base" "$value"]))) + (t/is (= "color" (get-in dtcg ["brand-core" "color" "primary-base" "$type"]))) + (t/is (= ["brand-core"] (get-in dtcg ["$metadata" "tokenSetOrder"]))) + (t/is (= #{"brand-core"} (set (get-in dtcg ["$metadata" "activeSets"]))))))) + +(t/deftest get-file-tokens-forbidden + (let [owner (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner) + :is-shared false}) + out (th/command! {::th/type :get-file-tokens + ::rpc/profile-id (:id other) + :file-id (:id file)})] + (t/is (th/ex-info? (:error out))) + (t/is (th/ex-of-type? (:error out) :not-found))))