From 2935731baba81bc8ba64e109a43a242cc8e12165 Mon Sep 17 00:00:00 2001 From: Muchiri Date: Tue, 27 Oct 2026 11:58:06 +0300 Subject: [PATCH 1/2] feat: add organizations on the v2 API Lets apps read and manage organizations through the v2 API, which lists organizations without their members and can filter them by the role the requesting user holds. The v1 functions are unchanged. Bumps the version to 0.9.4-rc2. --- project.clj | 2 +- src/milia/api/v2/organization.cljc | 115 ++++++++++++++++++ src/milia/utils/remote.cljc | 5 + test/clj/milia/api/v2/organization_test.clj | 128 ++++++++++++++++++++ test/clj/milia/utils/remote_test.clj | 6 + 5 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 src/milia/api/v2/organization.cljc create mode 100644 test/clj/milia/api/v2/organization_test.clj diff --git a/project.clj b/project.clj index 28ae4ac0..f5b6e772 100644 --- a/project.clj +++ b/project.clj @@ -8,7 +8,7 @@ :milia-http-default-per-route "10" :milia-http-threads "20"}) -(defproject onaio/milia "0.9.3-rc2" +(defproject onaio/milia "0.9.4-rc2" :description "The ona.io Clojure Web API Client." :dependencies [;; CORE MILIA REQUIREMENTS [cheshire "6.2.0"] diff --git a/src/milia/api/v2/organization.cljc b/src/milia/api/v2/organization.cljc new file mode 100644 index 00000000..e91dac2e --- /dev/null +++ b/src/milia/api/v2/organization.cljc @@ -0,0 +1,115 @@ +(ns milia.api.v2.organization + "Organizations through the v2 API. + + Unlike v1, an organization does not carry its `users`: its members, with + their roles, come from `members`, and the role of the user making the + request is the organization's `current_user_role`. The list only carries + what identifies each organization." + (:refer-clojure :exclude [update]) + (:require [milia.api.http :refer [parse-http]] + [milia.utils.remote :refer [make-v2-url]])) + +(def editor-role "editor") + +(defn all + "List the organizations the account making the request belongs to. + `shared-with` keeps only those the user with that username also belongs + to, and `role` only those where the account making the request holds that + role or a higher one." + [& {:keys [shared-with role]}] + (let [params (cond-> {} + shared-with (assoc :shared_with shared-with) + role (assoc :role role))] + (parse-http :get + (make-v2-url "orgs.json") + :http-options {:query-params params}))) + +(defn create [data] + (let [url (make-v2-url "orgs.json")] + (parse-http :post url :http-options {:form-params data} + :suppress-4xx-exceptions? true + :as-map? true))) + +(defn profile + [org-name & {:keys [no-cache?]}] + (when (seq org-name) + (let [url (make-v2-url "orgs" (str org-name ".json"))] + (parse-http :get url :no-cache? no-cache?)))) + +(defn retrieve-org-metadata + [username] + (:metadata (profile username))) + +(defn update + "update organization profile" + [params] + (let [org-name (:org params) + url (make-v2-url "orgs" (str org-name ".json")) + params (dissoc params :org)] + (when (seq org-name) + (parse-http + :patch + url + :http-options + #?(:clj {:form-params params + :content-type :json}) + #?(:cljs {:json-params params}) + :as-map? true)))) + +(defn members + "Every member of an organization with their role, as v1 returned them in + the organization's `users`." + [org-name] + (parse-http :get (make-v2-url "orgs" org-name "members.json"))) + +(defn add-member + "Add a user to an organization" + ([org-name member] + (add-member org-name member nil)) + ([org-name member role] + (let [url (make-v2-url "orgs" org-name "members.json") + data {:username member + :role (or role editor-role)}] + (parse-http :post + url + :http-options + #?(:clj {:form-params data + :content-type :json}) + #?(:cljs {:json-params data}) + :suppress-4xx-exceptions? true + :as-map? true)))) + +(defn change-member-role + "Change the role of an organization member" + [org-name member role] + (let [data {:username member + :role role}] + (parse-http :put + (make-v2-url "orgs" org-name "members.json") + :http-options + #?(:clj {:form-params data + :content-type :json}) + #?(:cljs {:json-params data}) + :as-map? true))) + +(defn remove-member + "Remove a user from an organization. v2 has no DELETE on the members: a + member is removed by updating them with `remove`." + [org-name member] + (let [data {:username member + :remove true}] + (parse-http :patch + (make-v2-url "orgs" org-name "members.json") + :http-options + #?(:clj {:form-params data + :content-type :json}) + #?(:cljs {:json-params data}) + :as-map? true))) + +(defn single-owner-member? + "Is user only members in org with owner role?" + [org-name] + (->> (members org-name) + (filter #(= "owner" (:role %))) + count + (= 1))) diff --git a/src/milia/utils/remote.cljc b/src/milia/utils/remote.cljc index d9b74a4f..bd9d4201 100644 --- a/src/milia/utils/remote.cljc +++ b/src/milia/utils/remote.cljc @@ -95,6 +95,11 @@ [& postfix] (url-join (str (protocol-prefixed (:data @hosts)) "/api/v1") postfix)) +(defn make-v2-url + "Build a v2 API url." + [& postfix] + (url-join (str (protocol-prefixed (:data @hosts)) "/api/v2") postfix)) + (defn make-client-url "Build a URL pointing to the client." [& postfix] diff --git a/test/clj/milia/api/v2/organization_test.clj b/test/clj/milia/api/v2/organization_test.clj new file mode 100644 index 00000000..051d7598 --- /dev/null +++ b/test/clj/milia/api/v2/organization_test.clj @@ -0,0 +1,128 @@ +(ns milia.api.v2.organization-test + (:refer-clojure :exclude [update]) + (:require [midje.sweet :refer :all] + [milia.api.v2.organization :refer :all] + [milia.api.http :refer [parse-http]] + [milia.utils.remote :refer [make-v2-url]])) + +(def url "fake-url") +(def org-name "fake-org-name") + +(facts "about all" + (fact "should list without filters" + (all) => :something + (provided + (make-v2-url "orgs.json") => url + (parse-http :get url :http-options {:query-params {}}) + => :something)) + + (fact "should pass the shared-with and role filters" + (all :shared-with "bob" :role "manager") => :something + (provided + (make-v2-url "orgs.json") => url + (parse-http :get + url + :http-options {:query-params {:shared_with "bob" + :role "manager"}}) + => :something))) + +(fact "about create" + (create :data) => :something + (provided + (make-v2-url "orgs.json") => url + (parse-http :post + url + :http-options {:form-params :data} + :suppress-4xx-exceptions? true + :as-map? true) => :something)) + +(facts "about profile" + (fact "should get the organization" + (profile org-name) => :something + (provided + (make-v2-url "orgs" (str org-name ".json")) => url + (parse-http :get url :no-cache? nil) => :something)) + + (fact "should not request without an organization" + (profile "") => nil)) + +(fact "should retrieve org metadata" + (retrieve-org-metadata org-name) => :metadata + (provided + (profile org-name) => {:metadata :metadata})) + +(fact "should update the organization" + (update {:org org-name :description "test"}) => :something + (provided + (make-v2-url "orgs" (str org-name ".json")) => url + (parse-http :patch + url + :http-options {:form-params {:description "test"} + :content-type :json} + :as-map? true) => :something)) + +(fact "about members" + (members org-name) => :something + (provided + (make-v2-url "orgs" org-name "members.json") => url + (parse-http :get url) => :something)) + +(facts "about add-member" + (fact "should add a member with the default role" + (add-member org-name "bob") => :something + (provided + (make-v2-url "orgs" org-name "members.json") => url + (parse-http :post + url + :http-options {:form-params {:username "bob" + :role editor-role} + :content-type :json} + :suppress-4xx-exceptions? true + :as-map? true) => :something)) + + (fact "should add a member with the given role" + (add-member org-name "bob" "owner") => :something + (provided + (make-v2-url "orgs" org-name "members.json") => url + (parse-http :post + url + :http-options {:form-params {:username "bob" + :role "owner"} + :content-type :json} + :suppress-4xx-exceptions? true + :as-map? true) => :something))) + +(fact "about change-member-role" + (change-member-role org-name "bob" "manager") => :something + (provided + (make-v2-url "orgs" org-name "members.json") => url + (parse-http :put + url + :http-options {:form-params {:username "bob" + :role "manager"} + :content-type :json} + :as-map? true) => :something)) + +(fact "should remove a member by updating them with remove" + (remove-member org-name "bob") => :something + (provided + (make-v2-url "orgs" org-name "members.json") => url + (parse-http :patch + url + :http-options {:form-params {:username "bob" + :remove true} + :content-type :json} + :as-map? true) => :something)) + +(facts "about single-owner-member?" + (fact "should be true with one owner" + (single-owner-member? org-name) => true + (provided + (members org-name) => [{:user "a" :role "owner"} + {:user "b" :role "manager"}])) + + (fact "should be false with several owners" + (single-owner-member? org-name) => false + (provided + (members org-name) => [{:user "a" :role "owner"} + {:user "b" :role "owner"}]))) diff --git a/test/clj/milia/utils/remote_test.clj b/test/clj/milia/utils/remote_test.clj index b18e5e8a..de2487f8 100644 --- a/test/clj/milia/utils/remote_test.clj +++ b/test/clj/milia/utils/remote_test.clj @@ -44,3 +44,9 @@ :password :password :temp-token :temp-token :token :token}))) + +(facts "about make-v2-url" + (fact "should build a url on the v2 API" + (make-v2-url "orgs" "bob.json") + => (str (protocol-prefixed (:data @hosts)) + "/api/v2/orgs/bob.json"))) From 0bc7fb75809333419a1ef0aed3b08b62469228cf Mon Sep 17 00:00:00 2001 From: Muchiri Date: Tue, 27 Oct 2026 14:13:05 +0300 Subject: [PATCH 2/2] fix: keep the single-owner check to Clojure In ClojureScript the members request returns a channel rather than the members, so the check could not work there. --- src/milia/api/v2/organization.cljc | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/milia/api/v2/organization.cljc b/src/milia/api/v2/organization.cljc index e91dac2e..648d6828 100644 --- a/src/milia/api/v2/organization.cljc +++ b/src/milia/api/v2/organization.cljc @@ -106,10 +106,12 @@ #?(:cljs {:json-params data}) :as-map? true))) -(defn single-owner-member? - "Is user only members in org with owner role?" - [org-name] - (->> (members org-name) - (filter #(= "owner" (:role %))) - count - (= 1))) +;; CLJ only: in CLJS `members` returns a channel, not the members +#?(:clj + (defn single-owner-member? + "Is user only members in org with owner role?" + [org-name] + (->> (members org-name) + (filter #(= "owner" (:role %))) + count + (= 1))))