diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a947a29..95f834a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ of [keepachangelog.com](http://keepachangelog.com/). ## [Unreleased] +## [30.68.70] - 2024-11-02 + +### Changed + +- Use PostgreSQL component (Integrant) for Porteiro source code instead of Datomic. + ## [30.67.70] - 2024-11-01 ### Added @@ -990,7 +996,9 @@ of [keepachangelog.com](http://keepachangelog.com/). - Add `loose-schema` function. -[Unreleased]: https://github.com/macielti/common-clj/compare/v30.67.70...HEAD +[Unreleased]: https://github.com/macielti/common-clj/compare/v30.68.70...HEAD + +[30.68.70]: https://github.com/macielti/common-clj/compare/v30.67.70...v30.68.70 [30.67.70]: https://github.com/macielti/common-clj/compare/v30.66.70...v30.67.70 diff --git a/project.clj b/project.clj index f61e195b..6f58e592 100644 --- a/project.clj +++ b/project.clj @@ -1,4 +1,4 @@ -(defproject net.clojars.macielti/common-clj "30.67.70" +(defproject net.clojars.macielti/common-clj "30.68.70" :description "Just common Clojure code that I use across projects" :url "https://github.com/macielti/common-clj" :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0" diff --git a/resources/migrations/002.create_customer_table.next.sql b/resources/migrations/002.create_customer_table.next.sql new file mode 100644 index 00000000..fadd9aaa --- /dev/null +++ b/resources/migrations/002.create_customer_table.next.sql @@ -0,0 +1,7 @@ +CREATE TABLE customers ( + id UUID PRIMARY KEY, + username VARCHAR(255) NOT NULL, + name VARCHAR(255), + roles TEXT[], + hashed_password VARCHAR(255) NOT NULL +); diff --git a/src/common_clj/io/interceptors/postgresql.clj b/src/common_clj/io/interceptors/postgresql.clj new file mode 100644 index 00000000..ff1ebb9f --- /dev/null +++ b/src/common_clj/io/interceptors/postgresql.clj @@ -0,0 +1,24 @@ +(ns common-clj.io.interceptors.postgresql + (:require [common-clj.error.core :as common-error] + [io.pedestal.interceptor :as pedestal.interceptor] + [pg.core :as pg] + [pg.pool :as pool] + [schema.core :as s])) + +(s/defn resource-existence-check-interceptor + "resource-identifier-fn -> function used to extract param used to query the resource, must receive a context as argument. + sql-query -> datomic query that will try to find the resource using the resource identifier" + [resource-identifier-fn + sql-query] + (pedestal.interceptor/interceptor {:name ::resource-existence-check-interceptor + :enter (fn [{{:keys [components]} :request :as context}] + (let [pool (:postgresql components) + resource-identifier (resource-identifier-fn context) + resource (-> (pool/with-connection [database-conn pool] + (pg/execute database-conn sql-query {:params [resource-identifier]})) first)] + (when-not resource + (common-error/http-friendly-exception 404 + "resource-not-found" + "Resource could not be found" + "Not Found"))) + context)})) diff --git a/src/common_clj/porteiro/adapters/customer.clj b/src/common_clj/porteiro/adapters/customer.clj index 77a62c8f..76e37740 100644 --- a/src/common_clj/porteiro/adapters/customer.clj +++ b/src/common_clj/porteiro/adapters/customer.clj @@ -33,3 +33,15 @@ (s/defn wire->internal-role :- s/Keyword [wire-role :- s/Str] (camel-snake-kebab/->kebab-case-keyword wire-role)) + +(s/defn internal-role->wire-role :- s/Str + [wire-role :- s/Keyword] + (camel-snake-kebab/->snake_case_string wire-role)) + +(s/defn postgresql->internal :- models.customer/Customer + [{:keys [id username roles name hashed_password]}] + (medley/assoc-some {:customer/id id + :customer/username username + :customer/hashed-password hashed_password + :customer/roles (map wire->internal-role roles)} + :customer/name name)) diff --git a/src/common_clj/porteiro/admin.clj b/src/common_clj/porteiro/admin.clj index 3130278f..b1337b76 100644 --- a/src/common_clj/porteiro/admin.clj +++ b/src/common_clj/porteiro/admin.clj @@ -1,15 +1,20 @@ (ns common-clj.porteiro.admin - (:require [common-clj.porteiro.db.datomic.customer :as database.customer] + (:require [common-clj.porteiro.db.datomic.customer :as datomic.customer] + [common-clj.porteiro.db.postgresql.customer :as postgresql.customer] [common-clj.porteiro.diplomat.http-server.customer :as diplomat.http-server.customer] [datomic.api :as d] [integrant.core :as ig] + [pg.pool :as pool] [taoensso.timbre :as log])) (defmethod ig/init-key ::admin [_ {:keys [components]}] (log/info :starting ::admin) (let [{:keys [admin-customer-seed]} (:config components)] - (when-not (database.customer/by-username (get-in admin-customer-seed [:customer :username]) (-> components :datomic d/db)) + (when-not (if (:datomic components) + (datomic.customer/by-username (get-in admin-customer-seed [:customer :username]) (-> components :datomic d/db)) + (pool/with-connection [database-conn (:postgresql components)] + (postgresql.customer/by-username (get-in admin-customer-seed [:customer :username]) database-conn))) (let [wire-customer-id (-> (diplomat.http-server.customer/create-customer! {:json-params admin-customer-seed :components components}) (get-in [:body :customer :id]))] diff --git a/src/common_clj/porteiro/controllers/customer.clj b/src/common_clj/porteiro/controllers/customer.clj index d73ce95c..aaa25977 100644 --- a/src/common_clj/porteiro/controllers/customer.clj +++ b/src/common_clj/porteiro/controllers/customer.clj @@ -3,16 +3,22 @@ [buddy.sign.jwt :as jwt] [common-clj.error.core :as common-error] [common-clj.porteiro.adapters.customer :as adapters.customer] - [common-clj.porteiro.db.datomic.customer :as database.customer] + [common-clj.porteiro.db.datomic.customer :as datomic.customer] + [common-clj.porteiro.db.postgresql.customer :as postgresql.customer] [common-clj.porteiro.models.customer :as models.customer] [datomic.api :as d] [java-time.api :as jt] + [pg.pool :as pool] [schema.core :as s])) (s/defn create-customer! :- models.customer/Customer [customer :- models.customer/Customer - datomic] - (database.customer/insert! customer datomic)) + datomic + postgresql] + (if datomic + (datomic.customer/insert! customer datomic) + (pool/with-connection [conn postgresql] + (postgresql.customer/insert! customer conn)))) (s/defn ->token :- s/Str [map :- {s/Keyword s/Any} @@ -24,8 +30,12 @@ (s/defn authenticate-customer! :- s/Str [{:keys [username password]} :- models.customer/CustomerAuthentication {:keys [jwt-secret]} - database] - (let [{:customer/keys [hashed-password] :as customer} (database.customer/by-username username database)] + datomic + postgresql] + (let [{:customer/keys [hashed-password] :as customer} (if datomic + (datomic.customer/by-username username (d/db datomic)) + (pool/with-connection [conn postgresql] + (postgresql.customer/by-username username conn)))] (if (and customer (:valid (hashers/verify password hashed-password))) (-> {:customer (adapters.customer/internal->wire customer)} (->token jwt-secret)) @@ -34,13 +44,31 @@ "Wrong username or/and password" "Customer is trying to login using invalid credentials")))) -(s/defn add-role! :- models.customer/Customer +(defmulti add-role! + (fn [_customer-id _role datomic postgresql] + (cond datomic :datomic + postgresql :postgresql))) + +(s/defmethod add-role! :datomic [customer-id :- s/Uuid role :- s/Keyword - datomic] - (if (database.customer/lookup customer-id (d/db datomic)) - (do (database.customer/add-role! customer-id role datomic) ;;TODO: Check how to make this transaction already return the updated entity - (database.customer/lookup customer-id (d/db datomic))) + datomic + _] + (if (datomic.customer/lookup customer-id (d/db datomic)) + (do (datomic.customer/add-role! customer-id role datomic) ;;TODO: Check how to make this transaction already return the updated entity + (datomic.customer/lookup customer-id (d/db datomic))) (throw (ex-info "Customer not found" {:status 404 :cause "Customer not found"})))) + +(s/defmethod add-role! :postgresql + [customer-id :- s/Uuid + role :- s/Keyword + _ + postgresql] + (pool/with-connection [conn postgresql] + (if (postgresql.customer/lookup customer-id conn) + (postgresql.customer/add-role! customer-id role conn) + (throw (ex-info "Customer not found" + {:status 404 + :cause "Customer not found"}))))) diff --git a/src/common_clj/porteiro/db/postgresql/customer.clj b/src/common_clj/porteiro/db/postgresql/customer.clj new file mode 100644 index 00000000..50b26531 --- /dev/null +++ b/src/common_clj/porteiro/db/postgresql/customer.clj @@ -0,0 +1,43 @@ +(ns common-clj.porteiro.db.postgresql.customer + (:require [common-clj.porteiro.adapters.customer :as adapters.customer] + [common-clj.porteiro.models.customer :as models.customer] + [pg.core :as pg] + [schema.core :as s])) + +(s/defn insert! :- models.customer/Customer + [{:customer/keys [id username name roles hashed-password]} :- models.customer/Customer + database-conn] + (-> (pg/execute database-conn + "INSERT INTO customers (id, username, name, roles, hashed_password) VALUES ($1, $2, $3, $4, $5) + returning *" + {:params [id username name (or roles []) hashed-password]}) + first + adapters.customer/postgresql->internal)) + +(s/defn by-username :- (s/maybe models.customer/Customer) + [username :- s/Str + database-conn] + (some-> (pg/execute database-conn + "SELECT * FROM customers WHERE username = $1" + {:params [username]}) + first + adapters.customer/postgresql->internal)) + +(s/defn lookup :- (s/maybe models.customer/Customer) + [customer-id :- s/Uuid + database-conn] + (some-> (pg/execute database-conn + "SELECT * FROM customers WHERE id = $1" + {:params [customer-id]}) + first + adapters.customer/postgresql->internal)) + +(s/defn add-role! :- (s/maybe models.customer/Customer) + [customer-id :- s/Uuid + role :- s/Keyword + database-conn] + (some-> (pg/execute database-conn + "UPDATE customers SET roles = array_append(roles, $1) WHERE id = $2 returning *" + {:params [(adapters.customer/internal-role->wire-role role) customer-id]}) + first + adapters.customer/postgresql->internal)) diff --git a/src/common_clj/porteiro/diplomat/http_server/customer.clj b/src/common_clj/porteiro/diplomat/http_server/customer.clj index c3669a90..2c9534ce 100644 --- a/src/common_clj/porteiro/diplomat/http_server/customer.clj +++ b/src/common_clj/porteiro/diplomat/http_server/customer.clj @@ -1,31 +1,30 @@ (ns common-clj.porteiro.diplomat.http-server.customer (:require [common-clj.porteiro.adapters.customer :as adapters.customer] [common-clj.porteiro.controllers.customer :as controllers.customer] - [datomic.api :as d] [schema.core :as s]) (:import (java.util UUID))) (s/defn create-customer! - [{{:keys [customer]} :json-params - {:keys [datomic]} :components}] + [{{:keys [customer]} :json-params + {:keys [datomic postgresql]} :components}] {:status 201 :body {:customer (-> (adapters.customer/wire->internal customer) - (controllers.customer/create-customer! datomic) + (controllers.customer/create-customer! datomic postgresql) adapters.customer/internal->wire)}}) (s/defn authenticate-customer! - [{{:keys [customer]} :json-params - {:keys [datomic config]} :components}] + [{{:keys [customer]} :json-params + {:keys [datomic postgresql config]} :components}] {:status 200 :body (-> (adapters.customer/wire->internal-customer-authentication customer) - (controllers.customer/authenticate-customer! config (d/db datomic)) + (controllers.customer/authenticate-customer! config datomic postgresql) adapters.customer/customer-token->wire)}) (s/defn add-role! [{{wire-customer-id :customer-id wire-role :role} :query-params - {:keys [datomic]} :components}] + {:keys [datomic postgresql]} :components}] {:status 200 :body (-> (UUID/fromString wire-customer-id) - (controllers.customer/add-role! (adapters.customer/wire->internal-role wire-role) datomic) + (controllers.customer/add-role! (adapters.customer/wire->internal-role wire-role) datomic postgresql) adapters.customer/internal->wire)}) diff --git a/src/common_clj/porteiro/interceptors/customer.clj b/src/common_clj/porteiro/interceptors/customer.clj index e58cc1b8..c94f0ba1 100644 --- a/src/common_clj/porteiro/interceptors/customer.clj +++ b/src/common_clj/porteiro/interceptors/customer.clj @@ -1,14 +1,19 @@ (ns common-clj.porteiro.interceptors.customer (:require [common-clj.error.core :as common-error] - [common-clj.porteiro.db.datomic.customer :as database.customer] - [datomic.api :as d])) + [common-clj.porteiro.db.datomic.customer :as datomic.customer] + [common-clj.porteiro.db.postgresql.customer :as postgresql.customer] + [datomic.api :as d] + [pg.pool :as pool])) (def username-already-in-use-interceptor {:name ::username-already-in-use-interceptor - :enter (fn [{{json-params :json-params - {:keys [datomic]} :components} :request :as context}] + :enter (fn [{{json-params :json-params + {:keys [datomic postgresql]} :components} :request :as context}] (let [username (get-in json-params [:customer :username] "") - customer (database.customer/by-username username (d/db datomic))] + customer (if datomic + (datomic.customer/by-username username (d/db datomic)) + (pool/with-connection [database-conn postgresql] + (postgresql.customer/by-username username database-conn)))] (when-not (empty? customer) (common-error/http-friendly-exception 409 "not-unique" diff --git a/test/unit/common_clj/porteiro/db/postgresql/customer_test.clj b/test/unit/common_clj/porteiro/db/postgresql/customer_test.clj new file mode 100644 index 00000000..b37385ce --- /dev/null +++ b/test/unit/common_clj/porteiro/db/postgresql/customer_test.clj @@ -0,0 +1,59 @@ +(ns common-clj.porteiro.db.postgresql.customer-test + (:require [clojure.test :refer :all] + [common-clj.integrant-components.postgresql :as postgresql] + [common-clj.porteiro.db.postgresql.customer :as database.customer] + [common-clj.porteiro.models.customer :as models.customer] + [matcher-combinators.test :refer [match?]] + [common-clj.test.helper.schema :as test.helper.schema] + [schema.test :as s])) + +(def customer-id (random-uuid)) +(def customer + (test.helper.schema/generate models.customer/Customer + {:customer/id customer-id + :customer/username "magal"})) + +(s/deftest insert-test + (testing "Should insert a customer" + (let [conn (postgresql/mocked-postgresql-conn)] + (is (match? {:customer/hashed-password string? + :customer/id uuid? + :customer/roles () + :customer/username string?} + (database.customer/insert! customer conn)))))) + +(s/deftest by-username-test + (testing "Should be able to query a customer by username" + (let [conn (postgresql/mocked-postgresql-conn)] + (database.customer/insert! customer conn) + (is (match? {:customer/id uuid? + :customer/username "magal" + :customer/hashed-password string? + :customer/roles []} + (database.customer/by-username "magal" conn))) + + (is (nil? (database.customer/by-username "random-username" conn)))))) + +(s/deftest lookup-test + (testing "Should be able to query a customer by id" + (let [conn (postgresql/mocked-postgresql-conn)] + (database.customer/insert! customer conn) + (is (match? {:customer/id uuid? + :customer/username "magal" + :customer/hashed-password string? + :customer/roles []} + (database.customer/lookup customer-id conn))) + + (is (nil? (database.customer/lookup (random-uuid) conn)))))) + +(s/deftest add-role-test + (testing "Should be able to query a customer by id" + (let [conn (postgresql/mocked-postgresql-conn)] + (database.customer/insert! customer conn) + (is (match? {:customer/id uuid? + :customer/username "magal" + :customer/hashed-password string? + :customer/roles [:test]} + (database.customer/add-role! customer-id :test conn))) + + (is (nil? (database.customer/lookup (random-uuid) conn))))))