diff --git a/deps.edn b/deps.edn index 99c2b38e..18aa8761 100644 --- a/deps.edn +++ b/deps.edn @@ -52,4 +52,4 @@ :codox/config {:description "Clojure-native implementation of GraphQL" - :source-uri "https://github.com/walmartlabs/lacinia/blob/master/{filepath}#L{line}"}} \ No newline at end of file + :source-uri "https://github.com/walmartlabs/lacinia/blob/master/{filepath}#L{line}"}} diff --git a/dev-resources/edn-federation.edn b/dev-resources/edn-federation.edn new file mode 100644 index 00000000..8001688f --- /dev/null +++ b/dev-resources/edn-federation.edn @@ -0,0 +1,81 @@ +{:roots + {:query :Query + :mutation :Mutation + :subscription :Subscription} + :objects + {:_Service + {:fields + {:sdl + {:type (non-null String)}}} + :User + {:fields + {:id + {:type (non-null Int)} + :name + {:type (non-null String)}} + :directives [{:directive-type :key :directive-args {:fields "id"}}]} + :Query + {:fields + {:user_by_id + {:type :User :args + {:id + {:type (non-null Int)}}}}} + :Account + {:fields + {:acct_number + {:type (non-null String)} :name + {:type (non-null String)}} + :directives [{:directive-type :key :directive-args {:fields "acct_number"}}]} + :Product + {:fields + {:upc + {:type (non-null String) :directives [{:directive-type :external}]} :reviewed_by + {:type :User}} + :directives [{:directive-type :key :directive-args {:fields "upc"}} + {:directive-type :extends}]}} + :scalars + {:_Any + {:parse :_Any/parser, + :serialize :_Any/serializer}, + :_FieldSet + {:parse :_FieldSet/parser, + :serialize :_FieldSet/serializer} + :link__Import + {:parse :link__Import/parser, + :serialize :link__Import/serializer}} + + :enums + {:link__Purpose + {:values [{:enum-value :SECURITY} {:enum-value :EXECUTION}]}} + + :directive-defs + {:external + {:locations #{:field-definition}} + :requires + {:locations #{:field-definition} + :args {:fields {:type (non-null _FieldSet)}}} + :provides + {:locations #{:field-definition} + :args {:fields {:type (non-null _FieldSet)}}} + :key + {:locations #{:object :interface} + :args {:fields {:type (non-null _FieldSet)} + :resolvable {:type Boolean :default-value true}}} + :link + {:locations #{:schema}, + :args {:url {:type String}, :as {:type String}, :for {:type :link__Purpose}, :import {:type (list :link__Import)}}} + :shareable {:locations #{:field-definition :object}}, + :inaccessible + {:locations + #{:enum + :input-field-definition + :interface + :input-object + :enum-value + :scalar + :argument-definition + :union + :field-definition + :object}}, + :override {:locations #{:field-definition}, :args {:from {:type (non-null String)}}}, + :extends {:locations #{:interface :object}}}} diff --git a/dev-resources/edn-federation.sdl b/dev-resources/edn-federation.sdl new file mode 100644 index 00000000..7274de15 --- /dev/null +++ b/dev-resources/edn-federation.sdl @@ -0,0 +1,43 @@ +schema { + query: Query + mutation: Mutation + subscription: Subscription +} + +directive @extends on INTERFACE | OBJECT +directive @key(fields: _FieldSet!, resolvable: Boolean = true) on INTERFACE | OBJECT +directive @external on FIELD_DEFINITION +directive @shareable on FIELD_DEFINITION | OBJECT +directive @requires(fields: _FieldSet!) on FIELD_DEFINITION +directive @link(url: String, as: String, for: link__Purpose, import: [link__Import]) on SCHEMA +directive @provides(fields: _FieldSet!) on FIELD_DEFINITION +directive @override(from: String!) on FIELD_DEFINITION +directive @inaccessible on ENUM | INPUT_FIELD_DEFINITION | INTERFACE | INPUT_OBJECT | ENUM_VALUE | SCALAR | ARGUMENT_DEFINITION | UNION | FIELD_DEFINITION | OBJECT + +scalar _Any +scalar _FieldSet +scalar link__Import + +enum link__Purpose{ + SECURITY + EXECUTION +} + +type _Service{ + sdl: String! +} +type User @key(fields: "id") { + id: Int! + name: String! +} +type Query{ + user_by_id(id: Int!): User +} +type Account @key(fields: "acct_number") { + acct_number: String! + name: String! +} +type Product @key(fields: "upc") @extends { + upc: String! + reviewed_by: User +} diff --git a/docs/federation/index.rst b/docs/federation/index.rst index 7049b350..5f39d466 100644 --- a/docs/federation/index.rst +++ b/docs/federation/index.rst @@ -14,11 +14,6 @@ service-spanning queries apart and build an overall query plan. Lacinia has been extended, starting in 0.38.0, to support acting as an implementing service; there is no plan at this time to act as a gateway. -.. warning:: - - At this time, only a schema defined with the :doc:`Schema Definition Language `, can be extended to act as - a service implementation. - Essentially, federation allows a set of services to each provide their own types, queries, and mutations, and organizes things so that each service can provide additional fields to the types provided by the other services. diff --git a/src/com/walmartlabs/lacinia/federation.clj b/src/com/walmartlabs/lacinia/federation.clj index c16dcb4c..0c97b32d 100644 --- a/src/com/walmartlabs/lacinia/federation.clj +++ b/src/com/walmartlabs/lacinia/federation.clj @@ -14,11 +14,12 @@ (ns com.walmartlabs.lacinia.federation (:require - [com.walmartlabs.lacinia.resolve :as resolve :refer [with-error]] - [com.walmartlabs.lacinia.internal-utils :as utils :refer [get-nested]] - [com.walmartlabs.lacinia.resolve-utils :as ru] - [com.walmartlabs.lacinia.schema :as schema] - [clojure.spec.alpha :as s])) + [com.walmartlabs.lacinia.resolve :as resolve :refer [with-error]] + [com.walmartlabs.lacinia.internal-utils :as utils :refer [get-nested]] + [com.walmartlabs.lacinia.resolve-utils :as ru] + [com.walmartlabs.lacinia.schema :as schema] + [clojure.spec.alpha :as s] + [clojure.string :refer [join escape]])) (def foundation-types "Map of annotations and types to automatically include into an SDL @@ -119,25 +120,264 @@ (ru/aggregate-results results #(maybe-wrap (reduce into [] %)))))))) +(defn ^:private apply-list + [f x] + (if (-> x first seq?) + (apply f x) + (f x))) + +(defn ^:private indent + [s] + (cond + (clojure.string/blank? s) "" + :else (str " " (clojure.string/replace s #"\n" "\n ")))) + +(defn ^:private edn-description->sdl-description + [description] + (if (nil? description) + "" + (str "\"\"\"\n" (escape description {\" "\\\""}) "\n\"\"\"\n"))) + +(defn ^:private edn-type->sdl-type + [type] + (if (seq? type) + (let [[hd & tl] type] + (cond + (nil? hd) "" + (= 'non-null hd) (str (apply-list edn-type->sdl-type tl) "!") + (= 'list hd) (str "[" (apply-list edn-type->sdl-type tl) "]") + (keyword? hd) (name hd) + (symbol? hd) (name hd))) + (recur (list type)))) + +(defn ^:private value->string + [value] + (cond + (string? value) (str "\"" (escape value {\" "\\\""}) "\"") + (keyword? value) (name value) + :else (str value))) + +(defn ^:private edn-default-value->sdl-default-value + [default-value] + (if (nil? default-value) + "" + (str " = " (value->string default-value)))) + +(defn ^:private edn-arg-descrption->sdl-arg-description + [description] + (if (nil? description) + "" + (str "\"" (escape description {\" "\\\""}) "\" "))) + +(defn ^:private edn-args->sdl-args + [args] + (if (nil? args) + "" + (str "(" (join ", " (map (fn [[arg-name {:keys [type default-value description]}]] (str (edn-arg-descrption->sdl-arg-description description) (name arg-name) ": " (edn-type->sdl-type type) (edn-default-value->sdl-default-value default-value))) args)) ")"))) + +(defn ^:private edn-directive-args->sdl-directive-args + [directive-args] + (if (nil? directive-args) + "" + (str "(" (->> directive-args + (map (fn [[arg-name arg-value]] (str (name arg-name) ": " (value->string arg-value)))) + (join ", ")) ")"))) + +(defn ^:private edn-directives->sdl-directives + [directives] + (if (nil? directives) + "" + (str " " + (->> directives + (map (fn [{:keys [directive-type directive-args]}] + (str "@" (name directive-type) (edn-directive-args->sdl-directive-args directive-args)))) + (join " ")) " "))) + +(defn ^:private edn-fields->sdl-fields + [fields] + (str + "{\n" + (->> fields + (map (fn [[field-name {:keys [type args description]}]] + (str (edn-description->sdl-description description) (name field-name) (edn-args->sdl-args args) ": " (edn-type->sdl-type type)))) + (join "\n") + indent) + "\n}")) + +(defn ^:private edn-implements->sdl-implements + [implements] + (if (seq implements) + (str " implements " (->> implements + (map name) + (join " & "))) + "")) + +(defn ^:private edn-objects->sdl-objects + [objects] + (->> objects + (map (fn [[key {:keys [fields directives description implements]}]] + (str (edn-description->sdl-description description) + "type " + (name key) + (edn-implements->sdl-implements implements) + (edn-directives->sdl-directives directives) + (edn-fields->sdl-fields fields)))) + (join "\n"))) + +(defn ^:private edn-interfaces->sdl-interfaces + [interfaces] + (->> interfaces + (map (fn [[key val]] + (str "interface " + (name key) + (-> val :fields edn-fields->sdl-fields)))) + (join "\n"))) + +(defn ^:private edn-input-objects->sdl-input-objects + [input-objects] + (->> input-objects + (map (fn [[key val]] + (str "input " + (name key) + (-> val :fields edn-fields->sdl-fields)))) + (join "\n"))) + +(defn ^:private edn-unions->sdl-unions + [unions] + (->> unions + (map (fn [[union-name {members :members}]] + (str "union " (name union-name) " = " (->> members + (map name) + (join " | "))))) + (join "\n"))) + +(defn ^:private edn-enum-value->sdl-enum-value + [enum-value] + (cond + (keyword? enum-value) enum-value + :else (:enum-value enum-value))) + +(defn ^:private edn-enums->sdl-enums + [enums] + (->> enums + (map (fn [[enum-name {values :values}]] + (str "enum " (name enum-name) "{\n" (->> values (map edn-enum-value->sdl-enum-value) (map name) (join "\n") indent) "\n}"))) + (join "\n"))) + +(defn ^:private edn-scalars->sdl-scalars + [scalars] + (->> (keys scalars) + (map name) + sort + (map #(str "scalar " %)) + (join "\n"))) + +(def directive-targets + {:enum "ENUM" + :input-field-definition "INPUT_FIELD_DEFINITION" + :interface "INTERFACE" + :input-object "INPUT_OBJECT" + :enum-value "ENUM_VALUE" + :scalar "SCALAR" + :argument-definition "ARGUMENT_DEFINITION" + :union "UNION" + :field-definition "FIELD_DEFINITION" + :object "OBJECT" + :schema "SCHEMA"}) + +(defn ^:private edn-directive-defs->sdl-directives + [directive-defs] + (->> directive-defs + (map (fn [[directive-name {:keys [locations args]}]] + (str "directive @" + (name directive-name) + (edn-args->sdl-args args) + " on " + (->> locations + (map directive-targets) + (join " | "))))) + (join "\n"))) + +(defn ^:private edn-roots->sdl-schema + [{:keys [query mutation subscription]}] + (cond-> "schema {" + (some? query) (str "\n query: " (name query)) + (some? mutation) (str "\n mutation: " (name mutation)) + (some? subscription) (str "\n subscription: " (name subscription)) + true (str "\n}")) + ) + +(defn ^:private fold-queries + [{:keys [queries] :as schema}] + (let [query (get-in schema [:roots :query] :Query)] + (cond + (map? queries) (update-in schema [:objects query :fields] merge queries) + :else schema))) + +(defn ^:private fold-mutations + [{:keys [mutations] :as schema}] + (let [mutation (get-in schema [:roots :mutation] :Mutation)] + (cond + (map? mutations) (update-in schema [:objects mutation :fields] merge mutations) + :else schema))) + +(defn ^:private fold-subscriptions + [{:keys [subscriptions] :as schema}] + (let [subscription (get-in schema [:roots :subscription] :Subscription)] + (cond + (map? subscriptions) (update-in schema [:objects subscription :fields] merge subscriptions) + :else schema))) + +(defn generate-sdl + "Translate the edn lacinia schema to the SDL schema." + [schema] + (->> schema + fold-queries + fold-mutations + fold-subscriptions + (sort-by #(-> % first {:directive-defs 1 + :scalars 2 + :enums 3 + :unions 4 + :interfaces 5 + :input-objects 6 + :objects 7})) + (map (fn [[key val]] + (case key + :objects (edn-objects->sdl-objects val) + :interfaces (edn-interfaces->sdl-interfaces val) + :scalars (edn-scalars->sdl-scalars val) + :unions (edn-unions->sdl-unions val) + :input-objects (edn-input-objects->sdl-input-objects val) + :enums (edn-enums->sdl-enums val) + :directive-defs (edn-directive-defs->sdl-directives val) + :roots (edn-roots->sdl-schema val) + ""))) + (join "\n\n") + clojure.string/trim)) + (defn inject-federation "Called after SDL parsing to extend the input schema - (not the compiled schema) with federation support." - [schema sdl entity-resolvers] - (let [entity-names (find-entity-names schema) - entities-resolver (entities-resolver-factory entity-names entity-resolvers) - query-root (get-nested schema [:roots :query] :Query)] - (prevent-collision schema [:unions :_Entity]) - (prevent-collision schema [:objects query-root :fields :_service]) - (prevent-collision schema [:objects query-root :fields :_entities]) - (cond-> (assoc-in schema [:objects query-root :fields :_service] - {:type '(non-null :_Service) - :resolve (fn [_ _ _] {:sdl sdl})}) - entity-names (-> (assoc-in [:unions :_Entity :members] entity-names) - (assoc-in [:objects query-root :fields :_entities] - {:type '(non-null (list :_Entity)) - :args - {:representations - {:type '(non-null (list (non-null :_Any)))}} - :resolve entities-resolver}))))) + (not the compiled schema) with federation support. + If the SDL string is not given, it is automatically created through the schema." + ([schema entity-resolvers] + (inject-federation schema (generate-sdl schema) entity-resolvers)) + ([schema sdl entity-resolvers] + (let [entity-names (find-entity-names schema) + entities-resolver (entities-resolver-factory entity-names entity-resolvers) + query-root (get-nested schema [:roots :query] :Query)] + (prevent-collision schema [:unions :_Entity]) + (prevent-collision schema [:objects query-root :fields :_service]) + (prevent-collision schema [:objects query-root :fields :_entities]) + (cond-> (assoc-in schema [:objects query-root :fields :_service] + {:type '(non-null :_Service) + :resolve (fn [_ _ _] {:sdl sdl})}) + entity-names (-> (assoc-in [:unions :_Entity :members] entity-names) + (assoc-in [:objects query-root :fields :_entities] + {:type '(non-null (list :_Entity)) + :args + {:representations + {:type '(non-null (list (non-null :_Any)))}} + :resolve entities-resolver})))))) (s/def ::entity-resolvers (s/map-of simple-keyword? ::schema/resolve)) diff --git a/src/com/walmartlabs/lacinia/parser/common.clj b/src/com/walmartlabs/lacinia/parser/common.clj index 06563e91..cc480310 100644 --- a/src/com/walmartlabs/lacinia/parser/common.clj +++ b/src/com/walmartlabs/lacinia/parser/common.clj @@ -156,7 +156,8 @@ (let [token-name* (token-name t p)] (when-not (ignored-terminal? token-name*) (list (keyword (str/lower-case token-name*)) - (.getText t)))))) + (cond-> (.getText t) + (#{"StringValue" "BlockStringValue"} token-name*) (clojure.string/replace #"\\\"" "\""))))))) (defn antlr-parse [grammar input-document] diff --git a/test/com/walmartlabs/lacinia/federation_tests.clj b/test/com/walmartlabs/lacinia/federation_tests.clj index fdacbc3f..71565ec5 100644 --- a/test/com/walmartlabs/lacinia/federation_tests.clj +++ b/test/com/walmartlabs/lacinia/federation_tests.clj @@ -15,12 +15,14 @@ (ns com.walmartlabs.lacinia.federation-tests (:require [clojure.test :refer [deftest is]] + [clojure.string :refer [trim]] [com.walmartlabs.lacinia.parser.schema :refer [parse-schema]] [com.walmartlabs.lacinia.resolve :refer [FieldResolver resolve-as]] [com.walmartlabs.lacinia.util :as util] [com.walmartlabs.test-utils :refer [execute]] [com.walmartlabs.test-reporting :refer [reporting]] - [com.walmartlabs.lacinia.schema :as schema])) + [com.walmartlabs.lacinia.schema :as schema] + [com.walmartlabs.lacinia.federation :refer [inject-federation generate-sdl]])) (defn ^:private resolve-user [_ {:keys [id]} _] @@ -265,3 +267,112 @@ query($reps : [_Any!]!) { (is (contains? field-names "_service")) (is (not (contains? field-names "_entities"))) (is (= #{"Stuff"} union-names))))) + +(deftest edn-schema->sdl-schema + (let [sample-edn-1 '{:roots {:query :MyQuery + :mutation :Mutation} + :interfaces + {:Node + {:fields + {:id + {:type (non-null ID)}}}} + :objects + {:MyQuery + {:fields + {:todo + {:type :Todo + :description "\"\"\"Get one todo item\"" + :args + {:id + {:type (non-null ID) + :default-value "\"default-node-id"}}} + :allTodos + {:type (non-null (list (non-null :Todo))) :description "List of all todo items"}}} + :Mutation + {:fields + {:addTodo + {:type (non-null :Todo) + :args + {:name + {:type (non-null String) :description "Name for the todo item"} + :priority + {:type :Priority :description "Priority level of todo item" :default-value :LOW}}} + :removeTodo + {:type (non-null :Todo) + :args + {:id + {:type (non-null ID)}}}}} + :Todo + {:implements [:Node] + :fields + {:id + {:type (non-null ID)} + :name + {:type (non-null String)} + :description + {:type String :description "Useful description for todo item"} + :priority + {:type (non-null :Priority)}}}} + :enums + {:Priority + {:values [{:enum-value :LOW} + {:enum-value :MEDIUM} + {:enum-value :HIGH}]}} + :unions + {:_Entity + {:members [:Todo]}} + :scalars + {:FieldSet + {}} + :directive-defs + {:key + {:locations #{:interface :object} + :args + {:fields + {:type (non-null :FieldSet)} + :resolvable + {:type Boolean :default-value true}}} + :external + {:locations #{:field-definition}}}} + sample-edn-2 '{:queries + {:node + {:description "node query" + :type Node + :args {:id {:type (non-null ID)}}}} + :roots + {:query :CustomQuery}} + sample-sdl-2 "schema {\n query: CustomQuery\n}\n\ntype CustomQuery{\n \"\"\"\n node query\n \"\"\"\n node(id: ID!): Node\n}"] + + (is (= (-> sample-edn-1 generate-sdl parse-schema) sample-edn-1)) + (is (= (generate-sdl sample-edn-2) sample-sdl-2)))) + +(deftest only-edn-schama-essential + (let [edn (-> "dev-resources/edn-federation.edn" slurp read-string) + sdl (-> "dev-resources/edn-federation.sdl" slurp trim) + schema (-> edn + (inject-federation {:User always-nil + :Account always-nil + :Product always-nil}) + (util/inject-resolvers {:Query/user_by_id resolve-user}) + (util/attach-scalar-transformers {:_Any/parser identity + :_Any/serializer identity + :_FieldSet/parser identity + :_FieldSet/serializer identity + :link__Import/parser identity + :link__Import/serializer identity}) + schema/compile)] + (is (= {:data {:_service {:sdl sdl}}} + (execute schema + "{ _service { sdl }}"))) + + (is (= {:data {:entities {:members [{:name "Account"} + {:name "Product"} + {:name "User"}] + :name "_Entity"}}} + (execute schema + "{ entities: __type(name: \"_Entity\") { name members: possibleTypes { name }}}"))) + + (is (= {:data {:user_by_id {:id 9998 + :name "User #9998"}}} + (execute schema + "{ user_by_id(id: 9998) { id name }}"))))) diff --git a/test/com/walmartlabs/lacinia/parser/schema_test.clj b/test/com/walmartlabs/lacinia/parser/schema_test.clj index 2a3ddd12..845033c9 100644 --- a/test/com/walmartlabs/lacinia/parser/schema_test.clj +++ b/test/com/walmartlabs/lacinia/parser/schema_test.clj @@ -356,13 +356,13 @@ :weight {:type (non-null Int)} :imperial {:type Boolean :default-value false} - :category {:type String :default-value "feline"}}}}} + :category {:type String :default-value "\"feline\""}}}}} (parse-string "input Animal { name: String! keyword: String = null weight: Int! imperial: Boolean = false - category: String = \"feline\" + category: String = \"\\\"feline\\\"\" }")))) (deftest extend-input-object