diff --git a/src/fluree/server/handler.clj b/src/fluree/server/handler.clj index 4c40293e..9f34dded 100644 --- a/src/fluree/server/handler.clj +++ b/src/fluree/server/handler.clj @@ -10,6 +10,7 @@ [fluree.server.handlers.create :as create] [fluree.server.handlers.drop :as drop] [fluree.server.handlers.ledger :as ledger] + [fluree.server.handlers.ledger-resource :as ledger-resource] [fluree.server.handlers.remote-resource :as remote] [fluree.server.handlers.subscription :as subscription] [fluree.server.handlers.transact :as srv-tx] @@ -116,10 +117,10 @@ (def QueryFormat (m/schema [:enum :sparql])) (def QueryRequestBody - (m/schema [:multi {:dispatch ::format} + (m/schema [:multi {:dispatch :sparql/format} [:sparql [:map - [::query SparqlQuery] - [::format QueryFormat]]] + [:sparql/query SparqlQuery] + [:sparql/format QueryFormat]]] [::m/default FqlQuery]])) (def HistoryQueryResponse @@ -387,9 +388,9 @@ (reify mf/Decode (decode [_ data charset] - {::query (String. (.readAllBytes ^InputStream data) - ^String charset) - ::format :sparql})))]})) + {:sparql/query (String. (.readAllBytes ^InputStream data) + ^String charset) + :sparql/format :sparql})))]})) (def sparql-update-format (mf/map->Format @@ -570,7 +571,23 @@ :parameters {:body SubscriptionRequestBody} :handler #'subscription/default}}]) +(def fluree-multi-query-routes + ["/:query" + {:conflicting true ; Allow this to conflict with specific routes + :get query-endpoint + :post query-endpoint}]) + +(def fluree-ledger-resource-routes + ["/{*ledger-path}" + {:conflicting true ; Allow this catch-all to conflict with specific routes + :middleware [ledger-resource/wrap-ledger-extraction] + :get {:summary "Ledger resource operations (GET)" + :handler #'ledger-resource/dispatch} + :post {:summary "Ledger resource operations (POST)" + :handler #'ledger-resource/dispatch}}]) + (def default-fluree-route-map + "Legacy routes maintained for backwards compatibility" {:create fluree-create-routes :drop fluree-drop-route :insert fluree-insert-route @@ -582,6 +599,11 @@ :remote fluree-remote-routes :subscription fluree-subscription-routes}) +(def new-fluree-route-map + "New routes with ledger-specific resources and multi-ledger query" + {:multi-query fluree-multi-query-routes + :ledger-resource fluree-ledger-resource-routes}) + (defn combine-fluree-routes [fluree-route-map] (->> fluree-route-map @@ -589,8 +611,16 @@ (into ["/fluree"]))) (def default-fluree-routes + "Legacy routes for backwards compatibility" (combine-fluree-routes default-fluree-route-map)) +(def all-fluree-routes + "Combined legacy and new routes - specific routes first, then catch-all routes" + (let [;; Order matters: specific routes must come before catch-all routes + specific-routes (vals default-fluree-route-map) + catch-all-routes (vals new-fluree-route-map)] + (into ["/fluree"] (concat specific-routes catch-all-routes)))) + (def fallback-handler (let [swagger-ui-handler (swagger-ui/create-swagger-ui-handler {:path "/" @@ -622,13 +652,15 @@ muuntaja-mw/format-request-middleware]] (ring/router all-routes {:data {:coercion coercer :muuntaja formatter - :middleware middleware}}))) + :middleware middleware} + ;; Allow conflicting routes - specific routes will be matched first + :conflicts nil}))) (defn app ([config] (app config [])) ([config custom-routes] - (app config custom-routes default-fluree-routes)) + (app config custom-routes all-fluree-routes)) ([config custom-routes fluree-routes] (let [app-middleware (compose-app-middleware config) app-routes (cond-> ["" {:middleware app-middleware} fluree-routes] diff --git a/src/fluree/server/handlers/ledger.clj b/src/fluree/server/handlers/ledger.clj index c6150e1a..72f31d76 100644 --- a/src/fluree/server/handlers/ledger.clj +++ b/src/fluree/server/handlers/ledger.clj @@ -1,12 +1,11 @@ (ns fluree.server.handlers.ledger (:require [fluree.db.api :as fluree] [fluree.db.util.log :as log] - [fluree.server.handler :as-alias handler] [fluree.server.handlers.shared :refer [defhandler deref!] :as shared])) (defhandler query [{:keys [fluree/conn fluree/opts] {:keys [body]} :parameters :as _req}] - (let [query (or (::handler/query body) body) + (let [query (or (:sparql/query body) body) {:keys [status result] :as query-response} (deref! (fluree/query-connection conn query opts))] (log/debug "query handler received query:" query opts) @@ -22,3 +21,4 @@ (if (and (map? result) (:status result) (:result result)) {:status (:status result), :body (:result result)} {:status 200, :body result}))) + diff --git a/src/fluree/server/handlers/ledger_resource.clj b/src/fluree/server/handlers/ledger_resource.clj new file mode 100644 index 00000000..80ac2bfe --- /dev/null +++ b/src/fluree/server/handlers/ledger_resource.clj @@ -0,0 +1,74 @@ +(ns fluree.server.handlers.ledger-resource + (:require [clojure.string :as str] + [fluree.db.util.log :as log] + [fluree.server.handlers.create :as create] + [fluree.server.handlers.drop :as drop] + [fluree.server.handlers.ledger :as ledger] + [fluree.server.handlers.shared :refer [defhandler]] + [fluree.server.handlers.transact :as tx])) + +(set! *warn-on-reflection* true) + +(def supported-operations + "Set of supported ledger operations as path suffixes" + #{":create" ":insert" ":upsert" ":update" ":delete" + ":history" ":drop"}) + +(defn parse-ledger-operation + "Parse a path to extract ledger name and operation. + Walks backwards through path segments looking for a known operation. + Returns {:ledger-name '...' :operation :insert/:upsert/etc} + + Examples: + 'my-ledger/:insert' -> {:ledger-name 'my-ledger' :operation :insert} + 'my/nested/ledger/:update' -> {:ledger-name 'my/nested/ledger' :operation :update} + 'my-ledger' -> {:ledger-name 'my-ledger' :operation nil}" + [path] + (let [path-parts (str/split path #"/") + reversed-parts (reverse path-parts)] + (loop [parts reversed-parts + seen-parts []] + (if-let [part (first parts)] + (if (supported-operations part) + ;; Found an operation, everything before it is the ledger name + {:ledger-name (str/join "/" (reverse (rest parts))) + :operation (keyword (subs part 1))} + ;; Keep looking, accumulating parts + (recur (rest parts) (conj seen-parts part))) + ;; No operation found, entire path is ledger name + {:ledger-name (str/join "/" (reverse seen-parts)) + :operation nil})))) + +(defn wrap-ledger-extraction + "Middleware to extract ledger name and operation from path. + Adds :ledger to fluree/opts and :fluree/operation to request." + [handler] + (fn [req] + (if-let [ledger-path (get-in req [:parameters :path :ledger-path])] + (let [{:keys [ledger-name operation]} (parse-ledger-operation ledger-path)] + (log/debug "Extracted ledger:" ledger-name "operation:" operation "from path:" ledger-path) + (-> req + (assoc-in [:fluree/opts :ledger] ledger-name) + (assoc :fluree/operation operation) + handler)) + (handler req)))) + +(defhandler dispatch + [{:keys [fluree/operation] :as req}] + (log/debug "Dispatching ledger resource operation:" operation) + (case operation + :create (create/default req) + :insert (tx/insert req) + :upsert (tx/upsert req) + :update (tx/update req) + :delete (tx/update req) ; delete is just an update operation + :drop (let [ledger-name (get-in req [:fluree/opts :ledger])] + (drop/drop-handler (assoc-in req [:parameters :body :ledger] ledger-name))) + :history (let [ledger-name (get-in req [:fluree/opts :ledger])] + (ledger/history (assoc-in req [:parameters :body :from] ledger-name))) + ;; No operation specified - default to query for GET, error for POST + (if (= :get (:request-method req)) + (ledger/query req) + (throw (ex-info "Operation required for POST requests to ledger resource" + {:status 400 + :error :db/invalid-operation}))))) \ No newline at end of file diff --git a/test/fluree/server/handlers/ledger_resource_test.clj b/test/fluree/server/handlers/ledger_resource_test.clj new file mode 100644 index 00000000..156b3b1c --- /dev/null +++ b/test/fluree/server/handlers/ledger_resource_test.clj @@ -0,0 +1,31 @@ +(ns fluree.server.handlers.ledger-resource-test + (:require [clojure.test :refer [deftest is testing]] + [fluree.server.handlers.ledger-resource :as lr])) + +(deftest parse-ledger-operation-test + (testing "Parse ledger operation from path" + (testing "Simple ledger with operation" + (is (= {:ledger-name "my-ledger" :operation :insert} + (lr/parse-ledger-operation "my-ledger/:insert")))) + + (testing "Nested ledger path with operation" + (is (= {:ledger-name "my/nested/ledger" :operation :update} + (lr/parse-ledger-operation "my/nested/ledger/:update")))) + + (testing "Ledger path without operation" + (is (= {:ledger-name "my-ledger" :operation nil} + (lr/parse-ledger-operation "my-ledger")))) + + (testing "Complex ledger name with slashes" + (is (= {:ledger-name "org/project/dataset" :operation :history} + (lr/parse-ledger-operation "org/project/dataset/:history")))) + + (testing "All supported operations" + (doseq [op [:create :insert :upsert :update :delete :history :drop]] + (let [path (str "test-ledger/:" (name op))] + (is (= {:ledger-name "test-ledger" :operation op} + (lr/parse-ledger-operation path)))))) + + (testing "Ledger name with multiple slashes and operation" + (is (= {:ledger-name "a/b/c/d/e" :operation :history} + (lr/parse-ledger-operation "a/b/c/d/e/:history")))))) \ No newline at end of file