From 25d96a84f6ca01c2402379e3f17067523fd0be97 Mon Sep 17 00:00:00 2001 From: Yohe-Am <56622350+Yohe-Am@users.noreply.github.com> Date: Wed, 7 Aug 2024 07:21:33 +0300 Subject: [PATCH] refactor: PreparedArgs api --- libs/metagen/src/client_py/mod.rs | 10 +- libs/metagen/src/client_py/static/client.py | 352 +++--- libs/metagen/src/client_ts/mod.rs | 12 +- libs/metagen/src/client_ts/static/mod.ts | 906 +++++++------- libs/metagen/src/tests/mod.rs | 2 +- libs/metagen/tests/client_ts/deno.json | 1 + libs/metagen/tests/client_ts/main.ts | 59 + typegate/tests/metagen/metagen_test.ts | 11 +- typegate/tests/metagen/typegraphs/sample.ts | 21 +- .../metagen/typegraphs/sample/py/client.py | 444 ++++--- .../metagen/typegraphs/sample/py/main.py | 89 +- .../metagen/typegraphs/sample/ts/client.ts | 1043 +++++++++-------- .../metagen/typegraphs/sample/ts/main.ts | 59 +- 13 files changed, 1771 insertions(+), 1238 deletions(-) create mode 100644 libs/metagen/tests/client_ts/deno.json create mode 100644 libs/metagen/tests/client_ts/main.ts diff --git a/libs/metagen/src/client_py/mod.rs b/libs/metagen/src/client_py/mod.rs index 85a07b731a..902aa51e27 100644 --- a/libs/metagen/src/client_py/mod.rs +++ b/libs/metagen/src/client_py/mod.rs @@ -135,7 +135,7 @@ fn render_client_py(_config: &ClienPyGenConfig, tg: &Typegraph) -> anyhow::Resul r#" class QueryGraph(QueryGraphBase): def __init__(self): - self.ty_to_gql_ty_map = {{"# + super().__init__({{"# )?; for ty_name in name_mapper.memo.borrow().deref().values() { write!( @@ -148,7 +148,7 @@ class QueryGraph(QueryGraphBase): write!( dest, r#" - }} + }}) "# )?; @@ -163,9 +163,11 @@ class QueryGraph(QueryGraphBase): fun.in_id.map(|id| data_types.get(&id).unwrap()), fun.select_ty.map(|id| selection_names.get(&id).unwrap()), ) { - (Some(arg_ty), Some(select_ty)) => format!("self, args: {arg_ty}, select: {select_ty}"), + (Some(arg_ty), Some(select_ty)) => { + format!("self, args: typing.Union[{arg_ty}, PlaceholderArgs], select: {select_ty}") + } // functions that return scalars don't need selections - (Some(arg_ty), None) => format!("self, args: {arg_ty}"), + (Some(arg_ty), None) => format!("self, args: typing.Union[{arg_ty}, PlaceholderArgs]"), // not all functions have args (empty struct arg) (None, Some(select_ty)) => format!("self, select: {select_ty}"), (None, None) => "self".into(), diff --git a/libs/metagen/src/client_py/static/client.py b/libs/metagen/src/client_py/static/client.py index 18d9ea60a2..cd67b1af74 100644 --- a/libs/metagen/src/client_py/static/client.py +++ b/libs/metagen/src/client_py/static/client.py @@ -6,95 +6,11 @@ import http.client as http_c -@dc.dataclass -class NodeArgValue: - type_name: str - value: typing.Any - - -NodeArgs = typing.Dict[str, NodeArgValue] -Out = typing.TypeVar("Out", covariant=True) - - -@dc.dataclass -class SelectNode(typing.Generic[Out]): - node_name: str - instance_name: str - args: typing.Optional[NodeArgs] - sub_nodes: typing.Optional[typing.List["SelectNode"]] - _phantom: typing.Optional[Out] = None - - -@dc.dataclass -class QueryNode(typing.Generic[Out], SelectNode[Out]): - pass - - -@dc.dataclass -class MutationNode(typing.Generic[Out], SelectNode[Out]): - pass - - -ArgT = typing.TypeVar("ArgT") -SelectionT = typing.TypeVar("SelectionT") - - -class Alias(typing.Generic[SelectionT]): - def __init__(self, **aliases: SelectionT): - self.items = aliases - - -ScalarSelectNoArgs = typing.Union[bool, Alias[typing.Literal[True]], None] -ScalarSelectArgs = typing.Union[ArgT, Alias[ArgT], typing.Literal[False], None] -CompositeSelectNoArgs = typing.Union[ - SelectionT, Alias[SelectionT], typing.Literal[False], None -] -CompositeSelectArgs = typing.Union[ - typing.Tuple[ArgT, SelectionT], - Alias[typing.Tuple[ArgT, SelectionT]], - typing.Literal[False], - None, -] - - -# FIXME: ideally this would be a TypedDict -# to allow full dict based queries but -# we need to reliably identify SelectionFlags at runtime -# but TypedDicts don't allow instanceof -@dc.dataclass -class SelectionFlags: - select_all: typing.Union[bool, None] = None - - -class Selection(typing.TypedDict, total=False): - _: SelectionFlags - - -SelectionGeneric = typing.Dict[ - str, - typing.Union[ - SelectionFlags, - ScalarSelectNoArgs, - ScalarSelectArgs[typing.Mapping[str, typing.Any]], - CompositeSelectNoArgs["SelectionGeneric"], - # FIXME: should be possible to make SelectionT here `SelectionGeneric` recursively - # but something breaks - CompositeSelectArgs[typing.Mapping[str, typing.Any], typing.Any], - ], -] - - -@dc.dataclass -class NodeMeta: - sub_nodes: typing.Optional[typing.Dict[str, typing.Callable[[], "NodeMeta"]]] = None - arg_types: typing.Optional[typing.Dict[str, str]] = None - - def selection_to_nodes( - selection: SelectionGeneric, - metas: typing.Dict[str, typing.Callable[[], NodeMeta]], + selection: "SelectionGeneric", + metas: typing.Dict[str, typing.Callable[[], "NodeMeta"]], parent_path: str, -) -> typing.List[SelectNode[typing.Any]]: +) -> typing.List["SelectNode[typing.Any]"]: out = [] flags = selection.get("_") if flags is not None and not isinstance(flags, SelectionFlags): @@ -188,8 +104,11 @@ def selection_to_nodes( + f"selection is typeof {type(instance_selection)}", ) sub_nodes = selection_to_nodes( - sub_selections, meta.sub_nodes, f"{parent_path}.{instance_name}" + typing.cast("SelectionGeneric", sub_selections), + meta.sub_nodes, + f"{parent_path}.{instance_name}", ) + node = SelectNode(node_name, instance_name, instance_args, sub_nodes) out.append(node) @@ -201,6 +120,149 @@ def selection_to_nodes( return out +# Util types section # + +Out = typing.TypeVar("Out", covariant=True) + +T = typing.TypeVar("T") + +ArgT = typing.TypeVar("ArgT", bound=typing.Mapping[str, typing.Any]) +SelectionT = typing.TypeVar("SelectionT") + +# Query node types section # + + +@dc.dataclass +class SelectNode(typing.Generic[Out]): + node_name: str + instance_name: str + args: typing.Optional["NodeArgs"] + sub_nodes: typing.Optional[typing.List["SelectNode"]] + + +@dc.dataclass +class QueryNode(SelectNode[Out]): + pass + + +@dc.dataclass +class MutationNode(SelectNode[Out]): + pass + + +@dc.dataclass +class NodeMeta: + sub_nodes: typing.Optional[typing.Dict[str, typing.Callable[[], "NodeMeta"]]] = None + arg_types: typing.Optional[typing.Dict[str, str]] = None + + +# Argument types section # + + +@dc.dataclass +class NodeArgValue: + type_name: str + value: typing.Any + + +NodeArgs = typing.Dict[str, NodeArgValue] + + +class PlaceholderValue(typing.Generic[T]): + def __init__(self, key: str): + self.key = key + + +PlaceholderArgs = typing.Dict[str, PlaceholderValue] + + +class PreparedArgs: + def get(self, key: str) -> PlaceholderValue: + return PlaceholderValue(key) + + +# Selection types section # + + +class Alias(typing.Generic[SelectionT]): + """ + Request multiple instances of a single node under different + aliases. + """ + + def __init__(self, **aliases: SelectionT): + self.items = aliases + + +ScalarSelectNoArgs = typing.Union[bool, Alias[typing.Literal[True]], None] +ScalarSelectArgs = typing.Union[ + ArgT, + PlaceholderArgs, + Alias[typing.Union[ArgT, PlaceholderArgs]], + typing.Literal[False], + None, +] +CompositeSelectNoArgs = typing.Union[ + SelectionT, Alias[SelectionT], typing.Literal[False], None +] +CompositeSelectArgs = typing.Union[ + typing.Tuple[typing.Union[ArgT, PlaceholderArgs], SelectionT], + Alias[typing.Tuple[typing.Union[ArgT, PlaceholderArgs], SelectionT]], + typing.Literal[False], + None, +] + + +# FIXME: ideally this would be a TypedDict +# to allow full dict based queries but +# we need to reliably identify SelectionFlags at runtime +# but TypedDicts don't allow instanceof +@dc.dataclass +class SelectionFlags: + select_all: typing.Union[bool, None] = None + + +class Selection(typing.TypedDict, total=False): + _: SelectionFlags + + +SelectionGeneric = typing.Mapping[ + str, + typing.Union[ + SelectionFlags, + ScalarSelectNoArgs, + ScalarSelectArgs[typing.Mapping[str, typing.Any]], + CompositeSelectNoArgs["SelectionGeneric"], + # FIXME: should be possible to make SelectionT here `SelectionGeneric` recursively + # but something breaks + CompositeSelectArgs[typing.Mapping[str, typing.Any], typing.Any], + ], +] + +# GraphQL section + + +@dc.dataclass +class GraphQLTransportOptions: + headers: typing.Dict[str, str] + + +@dc.dataclass +class GraphQLRequest: + addr: str + method: str + headers: typing.Dict[str, str] + body: bytes + + +@dc.dataclass +class GraphQLResponse: + req: GraphQLRequest + status: int + headers: typing.Dict[str, str] + body: bytes + + def convert_query_node_gql( node: SelectNode, variables: typing.Dict[str, NodeArgValue], @@ -227,27 +289,6 @@ def convert_query_node_gql( return out -@dc.dataclass -class GraphQLTransportOptions: - headers: typing.Dict[str, str] - - -@dc.dataclass -class GraphQLRequest: - addr: str - method: str - headers: typing.Dict[str, str] - body: bytes - - -@dc.dataclass -class GraphQLResponse: - req: GraphQLRequest - status: int - headers: typing.Dict[str, str] - body: bytes - - class GraphQLTransportBase: def __init__( self, @@ -261,7 +302,7 @@ def __init__( def build_gql( self, - query: typing.Dict[str, SelectNode], + query: typing.Mapping[str, SelectNode], ty: typing.Union[typing.Literal["query"], typing.Literal["mutation"]], name: str = "", ): @@ -354,45 +395,92 @@ def query( self, inp: typing.Dict[str, QueryNode[Out]], opts: typing.Optional[GraphQLTransportOptions] = None, + name: str = "", ) -> typing.Dict[str, Out]: - doc, variables = self.build_gql({key: val for key, val in inp.items()}, "query") + doc, variables = self.build_gql( + {key: val for key, val in inp.items()}, "query", name + ) # print(doc,variables) # return {} - out = self.fetch(doc, variables, opts) - return out + return self.fetch(doc, variables, opts) def mutation( self, inp: typing.Dict[str, MutationNode[Out]], opts: typing.Optional[GraphQLTransportOptions] = None, + name: str = "", ) -> typing.Dict[str, Out]: doc, variables = self.build_gql( - {key: val for key, val in inp.items()}, "mutation" + {key: val for key, val in inp.items()}, "mutation", name ) - out = self.fetch(doc, variables, opts) - return out + return self.fetch(doc, variables, opts) + def prepare_query( + self, + fun: typing.Callable[[PreparedArgs], typing.Dict[str, QueryNode[Out]]], + name: str = "", + ) -> "PreparedRequestUrlib[Out]": + return PreparedRequestUrlib(self, fun, "query", name) -# def queryT[Out]( -# self, inp: typing.Tuple[QueryNode[Out, typing.Any, typing.Any], *QueryNode[Out, typing.Any, typing.Any]] -# ) -> typing.Tuple[*Out]: -# return () + def prepare_mutation( + self, + fun: typing.Callable[[PreparedArgs], typing.Dict[str, MutationNode[Out]]], + name: str = "", + ) -> "PreparedRequestUrlib[Out]": + return PreparedRequestUrlib(self, fun, "mutation", name) -# def prepare_query[Args, K, Out]( -# self, -# argType: type[Args], -# inp: Callable[[Args], typing.Dict[K, SelectNode[Out, typing.Any, typing.Any]]], -# ) -> PreparedRequest[Args, K, Out]: -# return PreparedRequest(inp) +class PreparedRequestBase(typing.Generic[Out]): + def __init__( + self, + transport: GraphQLTransportBase, + fun: typing.Callable[[PreparedArgs], typing.Mapping[str, SelectNode[Out]]], + ty: typing.Union[typing.Literal["query"], typing.Literal["mutation"]], + name: str = "", + ): + dry_run_node = fun(PreparedArgs()) + doc, variables = transport.build_gql(dry_run_node, ty, name) + self.doc = doc + self._mapping = variables + self.transport = transport + + def resolve_vars( + self, + args: typing.Mapping[str, typing.Any], + mappings: typing.Dict[str, typing.Any], + ): + resolved: typing.Dict[str, typing.Any] = {} + for key, val in mappings.items(): + if isinstance(val, PlaceholderValue): + resolved[key] = args[val.key] + elif isinstance(val, dict): + self.resolve_vars(args, val) + else: + resolved[key] = val + return resolved + + +class PreparedRequestUrlib(PreparedRequestBase[Out]): + def __init__( + self, + transport: GraphQLTransportUrlib, + fun: typing.Callable[[PreparedArgs], typing.Mapping[str, SelectNode[Out]]], + ty: typing.Union[typing.Literal["query"], typing.Literal["mutation"]], + name: str = "", + ): + super().__init__(transport, fun, ty, name) + self.transport = transport + + def perform( + self, + args: typing.Mapping[str, typing.Any], + opts: typing.Optional[GraphQLTransportOptions] = None, + ) -> typing.Dict[str, Out]: + resolved_vars = self.resolve_vars(args, self._mapping) + return self.transport.fetch(self.doc, resolved_vars, opts) -class PreparedRequest(typing.Generic[ArgT, Out]): - def __init__(self, inp: typing.Callable[[ArgT], typing.Dict[str, SelectNode[Out]]]): - self.inp = inp - pass - def do(self, args: ArgT) -> typing.Dict[str, Out]: - return {} +# Query graph section # class QueryGraphBase: diff --git a/libs/metagen/src/client_ts/mod.rs b/libs/metagen/src/client_ts/mod.rs index 30f5843c5b..9461af4161 100644 --- a/libs/metagen/src/client_ts/mod.rs +++ b/libs/metagen/src/client_ts/mod.rs @@ -131,7 +131,7 @@ fn render_client_ts(_config: &ClienTsGenConfig, tg: &Typegraph) -> anyhow::Resul write!( dest, r#" -export class QueryGraph extends QueryGraphBase {{ +export class QueryGraph extends _QueryGraphBase {{ constructor() {{ super({{"# )?; @@ -162,9 +162,11 @@ export class QueryGraph extends QueryGraphBase {{ fun.in_id.map(|id| data_types.get(&id).unwrap()), fun.select_ty.map(|id| selection_names.get(&id).unwrap()), ) { - (Some(arg_ty), Some(select_ty)) => format!("args: {arg_ty}, select: {select_ty}"), + (Some(arg_ty), Some(select_ty)) => { + format!("args: {arg_ty} | PlaceholderArgs<{arg_ty}>, select: {select_ty}") + } // functions that return scalars don't need selections - (Some(arg_ty), None) => format!("args: {arg_ty}"), + (Some(arg_ty), None) => format!("args: {arg_ty} | PlaceholderArgs<{arg_ty}>"), // not all functions have args (empty struct arg) (None, Some(select_ty)) => format!("select: {select_ty}"), (None, None) => "".into(), @@ -191,7 +193,7 @@ export class QueryGraph extends QueryGraphBase {{ dest, r#" {method_name}({args_row}) {{ - const inner = selectionToNodeSet( + const inner = _selectionToNodeSet( {{ "{node_name}": {args_selection} }}, [["{node_name}", nodeMetas.{meta_method}]], "$q", @@ -348,7 +350,7 @@ fn e2e() -> anyhow::Result<()> { Ok(()) }) }, - target_dir: None, + target_dir: Some("./tests/client_ts/".into()), }]) .await })?; diff --git a/libs/metagen/src/client_ts/static/mod.ts b/libs/metagen/src/client_ts/static/mod.ts index fa6d4b91b4..416ba93db9 100644 --- a/libs/metagen/src/client_ts/static/mod.ts +++ b/libs/metagen/src/client_ts/static/mod.ts @@ -1,393 +1,4 @@ -type ErrorPolyfill = new (msg: string, payload: unknown) => Error; - -export type GraphQlTransportOptions = Omit & { - fetch?: typeof fetch; -}; - -export class GraphQLTransport { - constructor( - public address: URL, - public options: GraphQlTransportOptions, - private typeToGqlTypeMap: Record, - ) { - } - - protected buildGql( - query: Record, - ty: "query" | "mutation", - name: string = "", - ) { - const variables = new Map(); - - const rootNodes = Object - .entries(query) - .map(([key, node]) => { - const fixedNode = { ...node, instanceName: key }; - return convertQueryNodeGql(fixedNode, variables); - }) - .join("\n "); - - let argsRow = [...variables.entries()] - .map(([key, val]) => `$${key}: ${this.typeToGqlTypeMap[val.typeName]}`) - .join(", "); - if (argsRow.length > 0) { - // graphql doesn't like empty parentheses so we only - // add them if there are args - argsRow = `(${argsRow})`; - } - - const doc = `${ty} ${name}${argsRow} { - ${rootNodes} -}`; - return { - doc, - variables: Object.fromEntries( - [...variables.entries()] - .map(([key, val]) => [key, val.value]), - ), - }; - } - - protected async fetch( - doc: string, - variables: Record, - options?: GraphQlTransportOptions, - ) { - // console.log(doc, variables); - const fetchImpl = options?.fetch ?? this.options.fetch ?? fetch; - const res = await fetchImpl(this.address, { - ...this.options, - ...options, - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - ...this.options.headers ?? {}, - ...options?.headers ?? {}, - }, - body: JSON.stringify({ - query: doc, - variables, - }), - }); - if (!res.ok) { - const body = await res.text().catch((err) => - `error reading body: ${err}` - ); - throw new (Error as ErrorPolyfill)( - `graphql request to ${this.address} failed with status ${res.status}: ${body}`, - { - cause: { - response: res, - body, - }, - }, - ); - } - if (res.headers.get("content-type") != "application/json") { - throw new (Error as ErrorPolyfill)( - "unexpected content type in response", - { - cause: { - response: res, - body: await res.text().catch((err) => `error reading body: ${err}`), - }, - }, - ); - } - return await res.json() as { data: unknown; errors?: object[] }; - } - - async query>>( - query: Doc, - options?: GraphQlTransportOptions, - ): Promise> { - const { variables, doc } = this.buildGql( - Object.fromEntries( - Object.entries(query).map(( - [key, val], - ) => [key, (val as QueryNode).inner]), - ), - "query", - ); - const res = await this.fetch(doc, variables, options); - if ("errors" in res) { - throw new (Error as ErrorPolyfill)("graphql errors on response", { - cause: res.errors, - }); - } - return res.data as QueryDocOut; - } - - async mutation>>( - query: Doc, - options?: GraphQlTransportOptions, - ): Promise> { - const { variables, doc } = this.buildGql( - Object.fromEntries( - Object.entries(query).map(( - [key, val], - ) => [key, (val as MutationNode).inner]), - ), - "mutation", - ); - const res = await this.fetch(doc, variables, options); - if ("errors" in res) { - throw new (Error as ErrorPolyfill)("graphql errors on response", { - cause: res.errors, - }); - } - return res.data as QueryDocOut; - } - - prepareQuery< - T extends JsonObject, - Doc extends Record>, - >( - fun: (args: T) => Doc, - ): PreparedRequest { - return new PreparedRequest( - this.address, - this.options, - this.typeToGqlTypeMap, - fun, - "query", - ); - } - - prepareMutation< - T extends JsonObject, - Q extends Record>, - >( - fun: (args: T) => Q, - ): PreparedRequest { - return new PreparedRequest( - this.address, - this.options, - this.typeToGqlTypeMap, - fun, - "mutation", - ); - } -} - -const variablePath = Symbol("variablePath"); -const pathSeparator = "%.%"; - -class PreparedRequest< - T extends JsonObject, - Doc extends Record | MutationNode>, -> extends GraphQLTransport { - #doc: string; - #mappings: Record; - - constructor( - address: URL, - options: GraphQlTransportOptions, - typeToGqlTypeMap: Record, - fun: (args: T) => Doc, - ty: "query" | "mutation", - name: string = "", - ) { - super(address, options, typeToGqlTypeMap); - const rootId = "$root"; - const dryRunNode = fun(this.#getProxy(rootId) as unknown as T); - const { doc, variables } = this.buildGql( - Object.fromEntries( - Object.entries(dryRunNode).map(( - [key, val], - ) => [key, (val as MutationNode).inner]), - ), - ty, - name, - ); - this.#doc = doc; - this.#mappings = variables; - } - - resolveVariable( - args: T, - cur: Json, - path: string[], - idx = 0, - ): unknown { - if (idx == path.length - 1) { - return cur; - } - if (typeof cur != "object" || cur == null) { - const curPath = path.slice(0, idx); - throw new (Error as ErrorPolyfill)( - `unexpected prepard request arguments shape: item at ${curPath} is not object when trying to access ${path}`, - { - cause: { - args, - curPath, - curObj: cur, - mappings: this.#mappings, - }, - }, - ); - } - const childIdx = idx + 1; - return this.resolveVariable( - args, - (cur as JsonObject)[path[childIdx]], - path, - childIdx, - ); - } - - #getProxy(path: string): { [variablePath]: string } { - return new Proxy( - { [variablePath]: path }, - { - get: (target, prop, _reciever) => { - if (prop === variablePath) { - return path; - } - return this.#getProxy( - `${target[variablePath]}${pathSeparator}${String(prop)}`, - ); - }, - }, - ); - } - - async do(args: T, opts?: GraphQlTransportOptions): Promise< - { - [K in keyof Doc]: SelectNodeOut; - } - > { - const resolvedVariables = {} as Record; - for (const [key, val] of Object.entries(this.#mappings)) { - if (typeof val !== "object" || val == null || !(variablePath in val)) { - throw new Error("impossible"); - } - const path = (val[variablePath] as string).split(pathSeparator); - resolvedVariables[key] = this.resolveVariable(args, args, path); - } - // console.log(this.#doc, { - // resolvedVariables, - // mapping: this.#mappings, - // }); - const res = await this.fetch(this.#doc, resolvedVariables, opts); - if ("errors" in res) { - throw new (Error as ErrorPolyfill)("graphql errors on response", { - cause: res.errors, - }); - } - return res.data as QueryDocOut; - } -} - -type NodeArgValue = { - typeName: string; - value: unknown; -}; - -type NodeArgs = { - [name: string]: NodeArgValue; -}; - -export class Alias { - #aliases: Record; - constructor( - aliases: Record, - ) { - this.#aliases = aliases; - } - aliases() { - return this.#aliases; - } -} - -export function alias(aliases: Record): Alias { - return new Alias(aliases); -} - -type ScalarSelectNoArgs = - | boolean - | Alias - | null - | undefined; - -type ScalarSelectArgs = - | ArgT - | Alias - | false - | null - | undefined; - -type CompositeSelectNoArgs = - | SelectionT - | Alias - | false - | null - | undefined; - -type CompositeSelectArgs = - | [ArgT, SelectionT] - | Alias<[ArgT, SelectionT]> - | false - | undefined - | null; - -type SelectionFlags = "selectAll"; - -type Selection = { - _?: SelectionFlags; - [key: string]: - | SelectionFlags - | ScalarSelectNoArgs - | ScalarSelectArgs> - | CompositeSelectNoArgs - | CompositeSelectArgs, Selection> - | Selection; -}; - -type NodeMeta = { - subNodes?: [string, () => NodeMeta][]; - argumentTypes?: { [name: string]: string }; -}; - -export type JsonLiteral = string | number | boolean | null; -export type JsonObject = { [key: string]: Json }; -export type JsonArray = Json[]; -export type Json = JsonLiteral | JsonObject | JsonArray; - -export type DeArrayify = T extends Array ? Inner : T; - -type SelectNode = { - _phantom?: Out; - instanceName: string; - nodeName: string; - args?: NodeArgs; - subNodes?: SelectNode[]; -}; - -export class QueryNode { - constructor( - public inner: SelectNode, - ) {} -} - -export class MutationNode { - constructor( - public inner: SelectNode, - ) {} -} - -type SelectNodeOut = T extends (QueryNode | MutationNode) - ? O - : never; -type QueryDocOut = T extends - Record | MutationNode> ? { - [K in keyof T]: SelectNodeOut; - } - : never; - -// deno-lint-ignore no-unused-vars -function selectionToNodeSet( +function _selectionToNodeSet( selection: Selection, metas: [string, () => NodeMeta][], parentPath: string, @@ -429,6 +40,7 @@ function selectionToNodeSet( const node: SelectNode = { instanceName, nodeName }; if (argumentTypes) { + // make sure the arg is of the expected form let arg = instanceSelection; if (Array.isArray(arg)) { arg = arg[0]; @@ -441,26 +53,25 @@ function selectionToNodeSet( `is typeof ${typeof arg}`, ); } + const expectedArguments = new Map(Object.entries(argumentTypes)); - // TODO: consider logging a warning if `_` is detected incase user passes - // Selection as arg - node.args = Object.fromEntries( - Object.entries(arg) - .map(([key, value]) => { - const typeName = expectedArguments.get(key); - if (!typeName) { - throw new Error( - `unexpected argument ${key} at ${parentPath}.${instanceName}`, - ); - } - expectedArguments.delete(key); - return [key, { typeName, value }]; - }), - ); - // TODO: consider detecting required arguments here + node.args = {}; + for (const [key, value] of Object.entries(arg)) { + const typeName = expectedArguments.get(key); + // TODO: consider logging a warning if `_` is detected incase user passes + // Selection as arg + if (!typeName) { + throw new Error( + `unexpected argument ${key} at ${parentPath}.${instanceName}`, + ); + } + expectedArguments.delete(key); + node.args[key] = { typeName, value }; + } } if (subNodes) { + // sanity check selection object let subSelections = instanceSelection; if (argumentTypes) { if (!Array.isArray(subSelections)) { @@ -485,7 +96,8 @@ function selectionToNodeSet( `selection is typeof ${typeof nodeSelection}`, ); } - node.subNodes = selectionToNodeSet( + + node.subNodes = _selectionToNodeSet( // assume it's a Selection. If it's an argument // object, mismatch between the node desc should hopefully // catch it @@ -509,8 +121,180 @@ function selectionToNodeSet( return out; } -function convertQueryNodeGql( - node: SelectNode, +/* Query node types section */ + +type SelectNode<_Out = unknown> = { + nodeName: string; + instanceName: string; + args?: NodeArgs; + subNodes?: SelectNode[]; +}; + +export class QueryNode { + #inner: SelectNode; + constructor( + inner: SelectNode, + ) { + this.#inner = inner; + } + + inner() { + return this.#inner; + } +} + +export class MutationNode { + #inner: SelectNode; + constructor( + inner: SelectNode, + ) { + this.#inner = inner; + } + + inner() { + return this.#inner; + } +} + +type SelectNodeOut = T extends (QueryNode | MutationNode) + ? O + : never; +type QueryDocOut = T extends + Record | MutationNode> ? { + [K in keyof T]: SelectNodeOut; + } + : never; + +type NodeMeta = { + subNodes?: [string, () => NodeMeta][]; + argumentTypes?: { [name: string]: string }; +}; + +/* Selection types section */ + +type SelectionFlags = "selectAll"; + +type Selection = { + _?: SelectionFlags; + [key: string]: + | SelectionFlags + | ScalarSelectNoArgs + | ScalarSelectArgs> + | CompositeSelectNoArgs + | CompositeSelectArgs, Selection> + | Selection; +}; + +type ScalarSelectNoArgs = + | boolean + | Alias + | null + | undefined; + +type ScalarSelectArgs> = + | ArgT + | PlaceholderArgs + | Alias> + | false + | null + | undefined; + +type CompositeSelectNoArgs = + | SelectionT + | Alias + | false + | null + | undefined; + +type CompositeSelectArgs, SelectionT> = + | [ArgT | PlaceholderArgs, SelectionT] + | Alias<[ArgT | PlaceholderArgs, SelectionT]> + | false + | undefined + | null; + +/** + * Request multiple instances of a single node under different + * aliases. Look at {@link alias} for a functional way of instantiating + * this class. + */ +export class Alias { + #aliases: Record; + constructor( + aliases: Record, + ) { + this.#aliases = aliases; + } + aliases() { + return this.#aliases; + } +} + +/** + * Request multiple instances of a single node under different + * aliases. + */ +export function alias(aliases: Record): Alias { + return new Alias(aliases); +} + +/* Argument types section */ + +type NodeArgValue = { + typeName: string; + value: unknown; +}; + +type NodeArgs = { + [name: string]: NodeArgValue; +}; + +/** + * This object is passed to closures used for preparing requests + * ahead of time for {@link PreparedRequest}s. It allows one to + * get {@link PlaceholderValue}s that can be used in place of node + * arguments. At request time, the {@link PreparedRequest} then + * takes an object that adheres to `T` that can then be used + * to replace the placeholders. + */ +export class PreparedArgs> { + get(key: OnlyStringKeys): PlaceholderValue { + return new PlaceholderValue(key); + } +} + +/** + * Placeholder values for use by {@link PreparedRequest} + */ +export class PlaceholderValue<_T> { + #key: string; + constructor(key: string) { + this.#key = key; + } + + key() { + return this.#key; + } +} + +export type PlaceholderArgs> = { + [K in keyof T]: PlaceholderValue; +}; + +/* GraphQL section */ + +/** + * Options to be used for requests performed by {@link GraphQLTransport}. + */ +export type GraphQlTransportOptions = Omit & { + /** + * {@link fetch} implementaiton to use. Defaults to the one found in the environment + */ + fetch?: typeof fetch; +}; + +function convertQueryNodeGql( + node: SelectNode, variables: Map, ) { let out = node.nodeName == node.instanceName @@ -539,10 +323,310 @@ function convertQueryNodeGql( return out; } -// deno-lint-ignore no-unused-vars -class QueryGraphBase { +function buildGql( + typeToGqlTypeMap: Record, + query: Record, + ty: "query" | "mutation", + name: string = "", +) { + const variables = new Map(); + + const rootNodes = Object + .entries(query) + .map(([key, node]) => { + const fixedNode = { ...node, instanceName: key }; + return convertQueryNodeGql(fixedNode, variables); + }) + .join("\n "); + + let argsRow = [...variables.entries()] + .map(([key, val]) => `$${key}: ${typeToGqlTypeMap[val.typeName]}`) + .join(", "); + if (argsRow.length > 0) { + // graphql doesn't like empty parentheses so we only + // add them if there are args + argsRow = `(${argsRow})`; + } + + const doc = `${ty} ${name}${argsRow} { + ${rootNodes} +}`; + return { + doc, + variables: Object.fromEntries( + [...variables.entries()] + .map(([key, val]) => [key, val.value]), + ), + }; +} + +async function fetchGql( + addr: URL, + doc: string, + variables: Record, + options: GraphQlTransportOptions, +) { + // console.log(doc, variables); + const fetchImpl = options.fetch ?? fetch; + const res = await fetchImpl(addr, { + ...options, + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + ...options.headers ?? {}, + }, + body: JSON.stringify({ + query: doc, + variables, + }), + }); + if (!res.ok) { + const body = await res.text().catch((err) => `error reading body: ${err}`); + throw new (Error as ErrorPolyfill)( + `graphql request to ${addr} failed with status ${res.status}: ${body}`, + { + cause: { + response: res, + body, + }, + }, + ); + } + if (res.headers.get("content-type") != "application/json") { + throw new (Error as ErrorPolyfill)( + "unexpected content type in response", + { + cause: { + response: res, + body: await res.text().catch((err) => `error reading body: ${err}`), + }, + }, + ); + } + return await res.json() as { data: unknown; errors?: object[] }; +} + +/** + * Access the typegraph over it's exposed GraphQL API. + */ +export class GraphQLTransport { + constructor( + public address: URL, + public options: GraphQlTransportOptions, + private typeToGqlTypeMap: Record, + ) { + } + + async #request( + doc: string, + variables: Record, + options?: GraphQlTransportOptions, + ) { + const res = await fetchGql(this.address, doc, variables, { + ...this.options, + ...options, + }); + if ("errors" in res) { + throw new (Error as ErrorPolyfill)("graphql errors on response", { + cause: res.errors, + }); + } + return res.data; + } + + /** + * Make a query request to the typegraph. + */ + async query>>( + query: Doc, + { options, name = "" }: { + options?: GraphQlTransportOptions; + name?: string; + } = {}, + ): Promise> { + const { variables, doc } = buildGql( + this.typeToGqlTypeMap, + Object.fromEntries( + Object.entries(query).map(( + [key, val], + ) => [key, (val as QueryNode).inner()]), + ), + "query", + name, + ); + return await this.#request(doc, variables, options) as QueryDocOut; + } + + /** + * Make a mutation request to the typegraph. + */ + async mutation>>( + query: Doc, + { options, name = "" }: { + options?: GraphQlTransportOptions; + name?: string; + } = {}, + ): Promise> { + const { variables, doc } = buildGql( + this.typeToGqlTypeMap, + Object.fromEntries( + Object.entries(query).map(( + [key, val], + ) => [key, (val as MutationNode).inner()]), + ), + "mutation", + name, + ); + return await this.#request(doc, variables, options) as QueryDocOut; + } + + /** + * Prepare an ahead of time query {@link PreparedRequest}. + */ + prepareQuery< + T extends JsonObject, + Doc extends Record>, + >( + fun: (args: PreparedArgs) => Doc, + { name = "" }: { name?: string } = {}, + ): PreparedRequest { + return new PreparedRequest( + this.address, + this.options, + this.typeToGqlTypeMap, + fun, + "query", + name, + ); + } + + /** + * Prepare an ahead of time mutation {@link PreparedRequest}. + */ + prepareMutation< + T extends JsonObject, + Q extends Record>, + >( + fun: (args: PreparedArgs) => Q, + { name = "" }: { name?: string } = {}, + ): PreparedRequest { + return new PreparedRequest( + this.address, + this.options, + this.typeToGqlTypeMap, + fun, + "mutation", + name, + ); + } +} + +/** + * Prepares the GraphQL string ahead of time and allows re-use + * avoid the compute and garbage overhead of re-building it for + * repeat queries. + */ +export class PreparedRequest< + T extends JsonObject, + Doc extends Record | MutationNode>, +> { + public doc: string; + #mappings: Record; + + constructor( + private address: URL, + private options: GraphQlTransportOptions, + typeToGqlTypeMap: Record, + fun: (args: PreparedArgs) => Doc, + ty: "query" | "mutation", + name: string = "", + ) { + const args = new PreparedArgs(); + const dryRunNode = fun(args); + const { doc, variables } = buildGql( + typeToGqlTypeMap, + Object.fromEntries( + Object.entries(dryRunNode).map(( + [key, val], + ) => [key, (val as MutationNode).inner()]), + ), + ty, + name, + ); + this.doc = doc; + this.#mappings = variables; + } + + resolveVariables( + args: T, + mappings: Record, + ) { + const resolvedVariables = {} as Record; + for (const [key, val] of Object.entries(mappings)) { + if (val instanceof PlaceholderValue) { + resolvedVariables[key] = args[val.key()]; + } else if (typeof val == "object" && val != null) { + this.resolveVariables(args, val as JsonObject); + } else { + resolvedVariables[key] = val; + } + } + return resolvedVariables; + } + + /** + * Execute the prepared request. + */ + async perform(args: T, opts?: GraphQlTransportOptions): Promise< + { + [K in keyof Doc]: SelectNodeOut; + } + > { + const resolvedVariables = this.resolveVariables(args, this.#mappings); + // console.log(this.doc, { + // resolvedVariables, + // mapping: this.#mappings, + // }); + const res = await fetchGql( + this.address, + this.doc, + resolvedVariables, + { + ...this.options, + ...opts, + }, + ); + if ("errors" in res) { + throw new (Error as ErrorPolyfill)("graphql errors on response", { + cause: res.errors, + }); + } + return res.data as QueryDocOut; + } +} + +/* Util types section */ + +type OnlyStringKeys> = { + [K in keyof T]: K extends string ? K : never; +}[keyof T]; + +type JsonLiteral = string | number | boolean | null; +type JsonObject = { [key: string]: Json }; +type JsonArray = Json[]; +type Json = JsonLiteral | JsonObject | JsonArray; + +type ErrorPolyfill = new (msg: string, payload: unknown) => Error; + +/* QueryGraph section */ + +class _QueryGraphBase { constructor(private typeNameMapGql: Record) {} + /** + * Get the {@link GraphQLTransport} for the typegraph. + */ graphql(addr: URL | string, options?: GraphQlTransportOptions) { return new GraphQLTransport( new URL(addr), diff --git a/libs/metagen/src/tests/mod.rs b/libs/metagen/src/tests/mod.rs index 53b33430db..ff8a23c99b 100644 --- a/libs/metagen/src/tests/mod.rs +++ b/libs/metagen/src/tests/mod.rs @@ -80,7 +80,7 @@ pub async fn e2e_test(cases: Vec) -> anyhow::Result<()> { // TODO: query generated stub functions // cleanup - // tokio::fs::remove_dir_all(tmp_dir).await?; + tokio::fs::remove_dir_all(tmp_dir).await?; // node.try_undeploy(&typegraphs.keys().cloned().collect::>()).await?; } Ok(()) diff --git a/libs/metagen/tests/client_ts/deno.json b/libs/metagen/tests/client_ts/deno.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/libs/metagen/tests/client_ts/deno.json @@ -0,0 +1 @@ +{} diff --git a/libs/metagen/tests/client_ts/main.ts b/libs/metagen/tests/client_ts/main.ts new file mode 100644 index 0000000000..bfe8b366f9 --- /dev/null +++ b/libs/metagen/tests/client_ts/main.ts @@ -0,0 +1,59 @@ +// Copyright Metatype OÜ, licensed under the Elastic License 2.0. +// SPDX-License-Identifier: Elastic-2.0 + +import { alias, PreparedArgs, QueryGraph } from "./client.ts"; + +const api1 = new QueryGraph(); + +const gqlClient = api1.graphql( + `http://localhost:${Deno.env.get("TG_PORT")}/sample`, +); + +const prepared = gqlClient.prepareQuery(( + args: PreparedArgs<{ + id: string; + slug: string; + title: string; + }>, +) => ({ + scalarArgs: api1.scalarArgs({ + id: args.get("id"), + slug: args.get("slug"), + title: args.get("title"), + }), + compositeArgs: api1.compositeArgs({ id: args.get("id") }, { _: "selectAll" }), +})); + +const { scalarArgs, compositeArgs } = await prepared.do({ + id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + slug: "s", + title: "t", +}); + +const res = await gqlClient.query({ + // syntax very similar to typegraph definition + user: api1.getUser({ + _: "selectAll", + posts: alias({ + post1: { id: true, slug: true, title: true }, + post2: { _: "selectAll", id: false }, + }), + }), + posts: api1.getPosts({ _: "selectAll" }), + + scalarNoArgs: api1.scalarNoArgs(), + scalarArgs: api1.scalarArgs({ + id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + slug: "", + title: "", + }), + compositeNoArgs: api1.compositeNoArgs({ + _: "selectAll", + }), + compositeArgs: api1.compositeArgs({ + id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + }, { + _: "selectAll", + }), +}); +console.log(JSON.stringify(res)); diff --git a/typegate/tests/metagen/metagen_test.ts b/typegate/tests/metagen/metagen_test.ts index 6844aada15..678ab53795 100644 --- a/typegate/tests/metagen/metagen_test.ts +++ b/typegate/tests/metagen/metagen_test.ts @@ -463,7 +463,7 @@ Meta.test({ ).code, 0, ); - const expectedSchema = zod.object({ + const expectedSchemaQ = zod.object({ user: zod.object({ id: zod.string(), email: zod.string(), @@ -484,6 +484,8 @@ Meta.test({ title: zod.string(), }), scalarNoArgs: zod.string(), + }); + const expectedSchemaM = zod.object({ scalarArgs: zod.string(), compositeNoArgs: zod.object({ id: zod.string(), @@ -496,6 +498,13 @@ Meta.test({ title: zod.string(), }), }); + const expectedSchema = zod.tuple([ + expectedSchemaQ, + expectedSchemaQ, + expectedSchemaM, + expectedSchemaQ, + expectedSchemaM, + ]); const cases = [ { skip: false, diff --git a/typegate/tests/metagen/typegraphs/sample.ts b/typegate/tests/metagen/typegraphs/sample.ts index f889d430e1..88d1f62a98 100644 --- a/typegate/tests/metagen/typegraphs/sample.ts +++ b/typegate/tests/metagen/typegraphs/sample.ts @@ -1,7 +1,7 @@ // Copyright Metatype OÜ, licensed under the Elastic License 2.0. // SPDX-License-Identifier: Elastic-2.0 -import { Policy, t, typegraph } from "@typegraph/sdk/index.ts"; +import { fx, Policy, t, typegraph } from "@typegraph/sdk/index.ts"; import { DenoRuntime } from "@typegraph/sdk/runtimes/deno.ts"; import { RandomRuntime } from "@typegraph/sdk/runtimes/random.ts"; @@ -39,12 +39,25 @@ export const tg = await typegraph({ getPosts: random.gen(post), scalarNoArgs: random.gen(t.string()), - scalarArgs: deno.func(post, t.string(), { code: () => "hello" }), - compositeNoArgs: deno.func(t.struct({}), post, { code: genUser }), + scalarArgs: deno.func( + post, + t.string(), + { + code: () => "hello", + effect: fx.update(), + }, + ), + compositeNoArgs: deno.func(t.struct({}), post, { + code: genUser, + effect: fx.update(), + }), compositeArgs: deno.func( t.struct({ id: t.string() }), post, - { code: genUser }, + { + code: genUser, + effect: fx.update(), + }, ), }, Policy.public(), diff --git a/typegate/tests/metagen/typegraphs/sample/py/client.py b/typegate/tests/metagen/typegraphs/sample/py/client.py index c84ceedbae..9f88eb10bd 100644 --- a/typegate/tests/metagen/typegraphs/sample/py/client.py +++ b/typegate/tests/metagen/typegraphs/sample/py/client.py @@ -9,95 +9,11 @@ import http.client as http_c -@dc.dataclass -class NodeArgValue: - type_name: str - value: typing.Any - - -NodeArgs = typing.Dict[str, NodeArgValue] -Out = typing.TypeVar("Out", covariant=True) - - -@dc.dataclass -class SelectNode(typing.Generic[Out]): - node_name: str - instance_name: str - args: typing.Optional[NodeArgs] - sub_nodes: typing.Optional[typing.List["SelectNode"]] - _phantom: typing.Optional[Out] = None - - -@dc.dataclass -class QueryNode(typing.Generic[Out], SelectNode[Out]): - pass - - -@dc.dataclass -class MutationNode(typing.Generic[Out], SelectNode[Out]): - pass - - -ArgT = typing.TypeVar("ArgT") -SelectionT = typing.TypeVar("SelectionT") - - -class Alias(typing.Generic[SelectionT]): - def __init__(self, **aliases: SelectionT): - self.items = aliases - - -ScalarSelectNoArgs = typing.Union[bool, Alias[typing.Literal[True]], None] -ScalarSelectArgs = typing.Union[ArgT, Alias[ArgT], typing.Literal[False], None] -CompositeSelectNoArgs = typing.Union[ - SelectionT, Alias[SelectionT], typing.Literal[False], None -] -CompositeSelectArgs = typing.Union[ - typing.Tuple[ArgT, SelectionT], - Alias[typing.Tuple[ArgT, SelectionT]], - typing.Literal[False], - None, -] - - -# FIXME: ideally this would be a TypedDict -# to allow full dict based queries but -# we need to reliably identify SelectionFlags at runtime -# but TypedDicts don't allow instanceof -@dc.dataclass -class SelectionFlags: - select_all: typing.Union[bool, None] = None - - -class Selection(typing.TypedDict, total=False): - _: SelectionFlags - - -SelectionGeneric = typing.Dict[ - str, - typing.Union[ - SelectionFlags, - ScalarSelectNoArgs, - ScalarSelectArgs[typing.Mapping[str, typing.Any]], - CompositeSelectNoArgs["SelectionGeneric"], - # FIXME: should be possible to make SelectionT here `SelectionGeneric` recursively - # but something breaks - CompositeSelectArgs[typing.Mapping[str, typing.Any], typing.Any], - ], -] - - -@dc.dataclass -class NodeMeta: - sub_nodes: typing.Optional[typing.Dict[str, typing.Callable[[], "NodeMeta"]]] = None - arg_types: typing.Optional[typing.Dict[str, str]] = None - - def selection_to_nodes( - selection: SelectionGeneric, - metas: typing.Dict[str, typing.Callable[[], NodeMeta]], + selection: "SelectionGeneric", + metas: typing.Dict[str, typing.Callable[[], "NodeMeta"]], parent_path: str, -) -> typing.List[SelectNode[typing.Any]]: +) -> typing.List["SelectNode[typing.Any]"]: out = [] flags = selection.get("_") if flags is not None and not isinstance(flags, SelectionFlags): @@ -191,8 +107,11 @@ def selection_to_nodes( + f"selection is typeof {type(instance_selection)}", ) sub_nodes = selection_to_nodes( - sub_selections, meta.sub_nodes, f"{parent_path}.{instance_name}" + typing.cast("SelectionGeneric", sub_selections), + meta.sub_nodes, + f"{parent_path}.{instance_name}", ) + node = SelectNode(node_name, instance_name, instance_args, sub_nodes) out.append(node) @@ -204,6 +123,149 @@ def selection_to_nodes( return out +# Util types section # + +Out = typing.TypeVar("Out", covariant=True) + +T = typing.TypeVar("T") + +ArgT = typing.TypeVar("ArgT", bound=typing.Mapping[str, typing.Any]) +SelectionT = typing.TypeVar("SelectionT") + +# Query node types section # + + +@dc.dataclass +class SelectNode(typing.Generic[Out]): + node_name: str + instance_name: str + args: typing.Optional["NodeArgs"] + sub_nodes: typing.Optional[typing.List["SelectNode"]] + + +@dc.dataclass +class QueryNode(SelectNode[Out]): + pass + + +@dc.dataclass +class MutationNode(SelectNode[Out]): + pass + + +@dc.dataclass +class NodeMeta: + sub_nodes: typing.Optional[typing.Dict[str, typing.Callable[[], "NodeMeta"]]] = None + arg_types: typing.Optional[typing.Dict[str, str]] = None + + +# Argument types section # + + +@dc.dataclass +class NodeArgValue: + type_name: str + value: typing.Any + + +NodeArgs = typing.Dict[str, NodeArgValue] + + +class PlaceholderValue(typing.Generic[T]): + def __init__(self, key: str): + self.key = key + + +PlaceholderArgs = typing.Dict[str, PlaceholderValue] + + +class PreparedArgs: + def get(self, key: str) -> PlaceholderValue: + return PlaceholderValue(key) + + +# Selection types section # + + +class Alias(typing.Generic[SelectionT]): + """ + Request multiple instances of a single node under different + aliases. + """ + + def __init__(self, **aliases: SelectionT): + self.items = aliases + + +ScalarSelectNoArgs = typing.Union[bool, Alias[typing.Literal[True]], None] +ScalarSelectArgs = typing.Union[ + ArgT, + PlaceholderArgs, + Alias[typing.Union[ArgT, PlaceholderArgs]], + typing.Literal[False], + None, +] +CompositeSelectNoArgs = typing.Union[ + SelectionT, Alias[SelectionT], typing.Literal[False], None +] +CompositeSelectArgs = typing.Union[ + typing.Tuple[typing.Union[ArgT, PlaceholderArgs], SelectionT], + Alias[typing.Tuple[typing.Union[ArgT, PlaceholderArgs], SelectionT]], + typing.Literal[False], + None, +] + + +# FIXME: ideally this would be a TypedDict +# to allow full dict based queries but +# we need to reliably identify SelectionFlags at runtime +# but TypedDicts don't allow instanceof +@dc.dataclass +class SelectionFlags: + select_all: typing.Union[bool, None] = None + + +class Selection(typing.TypedDict, total=False): + _: SelectionFlags + + +SelectionGeneric = typing.Mapping[ + str, + typing.Union[ + SelectionFlags, + ScalarSelectNoArgs, + ScalarSelectArgs[typing.Mapping[str, typing.Any]], + CompositeSelectNoArgs["SelectionGeneric"], + # FIXME: should be possible to make SelectionT here `SelectionGeneric` recursively + # but something breaks + CompositeSelectArgs[typing.Mapping[str, typing.Any], typing.Any], + ], +] + +# GraphQL section + + +@dc.dataclass +class GraphQLTransportOptions: + headers: typing.Dict[str, str] + + +@dc.dataclass +class GraphQLRequest: + addr: str + method: str + headers: typing.Dict[str, str] + body: bytes + + +@dc.dataclass +class GraphQLResponse: + req: GraphQLRequest + status: int + headers: typing.Dict[str, str] + body: bytes + + def convert_query_node_gql( node: SelectNode, variables: typing.Dict[str, NodeArgValue], @@ -230,27 +292,6 @@ def convert_query_node_gql( return out -@dc.dataclass -class GraphQLTransportOptions: - headers: typing.Dict[str, str] - - -@dc.dataclass -class GraphQLRequest: - addr: str - method: str - headers: typing.Dict[str, str] - body: bytes - - -@dc.dataclass -class GraphQLResponse: - req: GraphQLRequest - status: int - headers: typing.Dict[str, str] - body: bytes - - class GraphQLTransportBase: def __init__( self, @@ -264,7 +305,7 @@ def __init__( def build_gql( self, - query: typing.Dict[str, SelectNode], + query: typing.Mapping[str, SelectNode], ty: typing.Union[typing.Literal["query"], typing.Literal["mutation"]], name: str = "", ): @@ -357,45 +398,92 @@ def query( self, inp: typing.Dict[str, QueryNode[Out]], opts: typing.Optional[GraphQLTransportOptions] = None, + name: str = "", ) -> typing.Dict[str, Out]: - doc, variables = self.build_gql({key: val for key, val in inp.items()}, "query") + doc, variables = self.build_gql( + {key: val for key, val in inp.items()}, "query", name + ) # print(doc,variables) # return {} - out = self.fetch(doc, variables, opts) - return out + return self.fetch(doc, variables, opts) def mutation( self, inp: typing.Dict[str, MutationNode[Out]], opts: typing.Optional[GraphQLTransportOptions] = None, + name: str = "", ) -> typing.Dict[str, Out]: doc, variables = self.build_gql( - {key: val for key, val in inp.items()}, "mutation" + {key: val for key, val in inp.items()}, "mutation", name ) - out = self.fetch(doc, variables, opts) - return out + return self.fetch(doc, variables, opts) + def prepare_query( + self, + fun: typing.Callable[[PreparedArgs], typing.Dict[str, QueryNode[Out]]], + name: str = "", + ) -> "PreparedRequestUrlib[Out]": + return PreparedRequestUrlib(self, fun, "query", name) + + def prepare_mutation( + self, + fun: typing.Callable[[PreparedArgs], typing.Dict[str, MutationNode[Out]]], + name: str = "", + ) -> "PreparedRequestUrlib[Out]": + return PreparedRequestUrlib(self, fun, "mutation", name) -# def queryT[Out]( -# self, inp: typing.Tuple[QueryNode[Out, typing.Any, typing.Any], *QueryNode[Out, typing.Any, typing.Any]] -# ) -> typing.Tuple[*Out]: -# return () -# def prepare_query[Args, K, Out]( -# self, -# argType: type[Args], -# inp: Callable[[Args], typing.Dict[K, SelectNode[Out, typing.Any, typing.Any]]], -# ) -> PreparedRequest[Args, K, Out]: -# return PreparedRequest(inp) +class PreparedRequestBase(typing.Generic[Out]): + def __init__( + self, + transport: GraphQLTransportBase, + fun: typing.Callable[[PreparedArgs], typing.Mapping[str, SelectNode[Out]]], + ty: typing.Union[typing.Literal["query"], typing.Literal["mutation"]], + name: str = "", + ): + dry_run_node = fun(PreparedArgs()) + doc, variables = transport.build_gql(dry_run_node, ty, name) + self.doc = doc + self._mapping = variables + self.transport = transport + + def resolve_vars( + self, + args: typing.Mapping[str, typing.Any], + mappings: typing.Dict[str, typing.Any], + ): + resolved: typing.Dict[str, typing.Any] = {} + for key, val in mappings.items(): + if isinstance(val, PlaceholderValue): + resolved[key] = args[val.key] + elif isinstance(val, dict): + self.resolve_vars(args, val) + else: + resolved[key] = val + return resolved + + +class PreparedRequestUrlib(PreparedRequestBase[Out]): + def __init__( + self, + transport: GraphQLTransportUrlib, + fun: typing.Callable[[PreparedArgs], typing.Mapping[str, SelectNode[Out]]], + ty: typing.Union[typing.Literal["query"], typing.Literal["mutation"]], + name: str = "", + ): + super().__init__(transport, fun, ty, name) + self.transport = transport + def perform( + self, + args: typing.Mapping[str, typing.Any], + opts: typing.Optional[GraphQLTransportOptions] = None, + ) -> typing.Dict[str, Out]: + resolved_vars = self.resolve_vars(args, self._mapping) + return self.transport.fetch(self.doc, resolved_vars, opts) -class PreparedRequest(typing.Generic[ArgT, Out]): - def __init__(self, inp: typing.Callable[[ArgT], typing.Dict[str, SelectNode[Out]]]): - self.inp = inp - pass - def do(self, args: ArgT) -> typing.Dict[str, Out]: - return {} +# Query graph section # class QueryGraphBase: @@ -429,18 +517,25 @@ def Post(): ) @staticmethod - def Func27(): + def User(): return NodeMeta( - sub_nodes=NodeDescs.Post().sub_nodes, + sub_nodes={ + "id": NodeDescs.scalar, + "email": NodeDescs.scalar, + "posts": NodeDescs.Post, + }, ) @staticmethod - def Func28(): + def Func23(): return NodeMeta( - sub_nodes=NodeDescs.Post().sub_nodes, - arg_types={ - "id": "String13", - }, + sub_nodes=NodeDescs.User().sub_nodes, + ) + + @staticmethod + def Func25(): + return NodeMeta( + sub_nodes=NodeDescs.scalar().sub_nodes, ) @staticmethod @@ -455,34 +550,35 @@ def Func26(): ) @staticmethod - def Func25(): - return NodeMeta( - sub_nodes=NodeDescs.scalar().sub_nodes, - ) - - @staticmethod - def Func24(): + def Func27(): return NodeMeta( sub_nodes=NodeDescs.Post().sub_nodes, ) @staticmethod - def User(): + def Func28(): return NodeMeta( - sub_nodes={ - "id": NodeDescs.scalar, - "email": NodeDescs.scalar, - "posts": NodeDescs.Post, + sub_nodes=NodeDescs.Post().sub_nodes, + arg_types={ + "id": "String13", }, ) @staticmethod - def Func23(): + def Func24(): return NodeMeta( - sub_nodes=NodeDescs.User().sub_nodes, + sub_nodes=NodeDescs.Post().sub_nodes, ) +Object21 = typing.TypedDict( + "Object21", + { + "id": str, + }, + total=False, +) + StringUuid = str Post = typing.TypedDict( @@ -509,14 +605,6 @@ def Func23(): total=False, ) -Object21 = typing.TypedDict( - "Object21", - { - "id": str, - }, - total=False, -) - PostSelections = typing.TypedDict( "PostSelections", @@ -543,11 +631,13 @@ def Func23(): class QueryGraph(QueryGraphBase): def __init__(self): - self.ty_to_gql_ty_map = { - "String4": "Any", - "String1": "Any", - "String13": "Any", - } + super().__init__( + { + "String4": "Any", + "String1": "Any", + "String13": "Any", + } + ) def get_user(self, select: UserSelections) -> QueryNode[User]: node = selection_to_nodes( @@ -567,20 +657,30 @@ def scalar_no_args(self) -> QueryNode[str]: )[0] return QueryNode(node.node_name, node.instance_name, node.args, node.sub_nodes) - def scalar_args(self, args: Post) -> QueryNode[str]: + def scalar_args( + self, args: typing.Union[Post, PlaceholderArgs] + ) -> MutationNode[str]: node = selection_to_nodes( {"scalarArgs": args}, {"scalarArgs": NodeDescs.Func26}, "$q" )[0] - return QueryNode(node.node_name, node.instance_name, node.args, node.sub_nodes) + return MutationNode( + node.node_name, node.instance_name, node.args, node.sub_nodes + ) - def composite_no_args(self, select: PostSelections) -> QueryNode[Post]: + def composite_no_args(self, select: PostSelections) -> MutationNode[Post]: node = selection_to_nodes( {"compositeNoArgs": select}, {"compositeNoArgs": NodeDescs.Func27}, "$q" )[0] - return QueryNode(node.node_name, node.instance_name, node.args, node.sub_nodes) + return MutationNode( + node.node_name, node.instance_name, node.args, node.sub_nodes + ) - def composite_args(self, args: Object21, select: PostSelections) -> QueryNode[Post]: + def composite_args( + self, args: typing.Union[Object21, PlaceholderArgs], select: PostSelections + ) -> MutationNode[Post]: node = selection_to_nodes( {"compositeArgs": (args, select)}, {"compositeArgs": NodeDescs.Func28}, "$q" )[0] - return QueryNode(node.node_name, node.instance_name, node.args, node.sub_nodes) + return MutationNode( + node.node_name, node.instance_name, node.args, node.sub_nodes + ) diff --git a/typegate/tests/metagen/typegraphs/sample/py/main.py b/typegate/tests/metagen/typegraphs/sample/py/main.py index 7829a922a8..084f56a775 100644 --- a/typegate/tests/metagen/typegraphs/sample/py/main.py +++ b/typegate/tests/metagen/typegraphs/sample/py/main.py @@ -1,4 +1,10 @@ -from client import QueryGraph, PostSelections, SelectionFlags, UserSelections, Alias +from client import ( + QueryGraph, + PostSelections, + SelectionFlags, + UserSelections, + Alias, +) import json import os @@ -6,7 +12,60 @@ port = os.getenv("TG_PORT") gql_client = qg.graphql_sync(f"http://localhost:{port}/sample") -res = gql_client.query( +prepared_q = gql_client.prepare_query( + lambda args: { + "user": qg.get_user( + UserSelections( + _=SelectionFlags(select_all=True), + posts=Alias( + post1=PostSelections( + id=True, + slug=True, + title=True, + ), + post2=PostSelections( + _=SelectionFlags(select_all=True), + id=False, + ), + ), + ), + ), + "posts": qg.get_posts({"_": SelectionFlags(select_all=True)}), + "scalarNoArgs": qg.scalar_no_args(), + }, +) + +prepared_m = gql_client.prepare_mutation( + lambda args: { + "scalarArgs": qg.scalar_args( + { + "id": args.get("id"), + "slug": args.get("slug"), + "title": args.get("title"), + } + ), + "compositeNoArgs": qg.composite_no_args({"_": SelectionFlags(select_all=True)}), + "compositeArgs": qg.composite_args( + { + "id": args.get("id"), + }, + {"_": SelectionFlags(select_all=True)}, + ), + }, +) + +res1 = prepared_q.perform({}) +res1a = prepared_q.perform({}) + +res2 = prepared_m.perform( + { + "id": "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + "slug": "s", + "title": "t", + } +) + +res3 = gql_client.query( { "user": qg.get_user( UserSelections( @@ -26,6 +85,11 @@ ), "posts": qg.get_posts({"_": SelectionFlags(select_all=True)}), "scalarNoArgs": qg.scalar_no_args(), + } +) + +res4 = gql_client.mutation( + { "scalarArgs": qg.scalar_args( { "id": "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", @@ -43,23 +107,4 @@ } ) -print(json.dumps(res)) - -# prepared = gql_client.prepare_query( -# str, -# lambda args: { -# "user": qg.get_user( -# UserArgs(id="1234"), -# UserSelectParams( -# id=True, -# email=True, -# posts=(PostArgs(filter="top"), PostSelectParams(slug=True, title=True)), -# ), -# ), -# "posts": qg.get_post( -# PostArgs(filter="today"), PostSelectParams(slug=True, title=True) -# ), -# }, -# ) -# -# out = prepared.do("arg") +print(json.dumps([res1, res1a, res2, res3, res4])) diff --git a/typegate/tests/metagen/typegraphs/sample/ts/client.ts b/typegate/tests/metagen/typegraphs/sample/ts/client.ts index 668a7354c3..b0fa724f1a 100644 --- a/typegate/tests/metagen/typegraphs/sample/ts/client.ts +++ b/typegate/tests/metagen/typegraphs/sample/ts/client.ts @@ -1,395 +1,7 @@ // This file was @generated by metagen and is intended // to be generated again on subsequent metagen runs. -type ErrorPolyfill = new (msg: string, payload: unknown) => Error; - -export type GraphQlTransportOptions = Omit & { - fetch?: typeof fetch; -}; - -export class GraphQLTransport { - constructor( - public address: URL, - public options: GraphQlTransportOptions, - private typeToGqlTypeMap: Record, - ) { - } - - protected buildGql( - query: Record, - ty: "query" | "mutation", - name: string = "", - ) { - const variables = new Map(); - - const rootNodes = Object - .entries(query) - .map(([key, node]) => { - const fixedNode = { ...node, instanceName: key }; - return convertQueryNodeGql(fixedNode, variables); - }) - .join("\n "); - - let argsRow = [...variables.entries()] - .map(([key, val]) => `$${key}: ${this.typeToGqlTypeMap[val.typeName]}`) - .join(", "); - if (argsRow.length > 0) { - // graphql doesn't like empty parentheses so we only - // add them if there are args - argsRow = `(${argsRow})`; - } - - const doc = `${ty} ${name}${argsRow} { - ${rootNodes} -}`; - return { - doc, - variables: Object.fromEntries( - [...variables.entries()] - .map(([key, val]) => [key, val.value]), - ), - }; - } - - protected async fetch( - doc: string, - variables: Record, - options?: GraphQlTransportOptions, - ) { - // console.log(doc, variables); - const fetchImpl = options?.fetch ?? this.options.fetch ?? fetch; - const res = await fetchImpl(this.address, { - ...this.options, - ...options, - method: "POST", - headers: { - accept: "application/json", - "content-type": "application/json", - ...this.options.headers ?? {}, - ...options?.headers ?? {}, - }, - body: JSON.stringify({ - query: doc, - variables, - }), - }); - if (!res.ok) { - const body = await res.text().catch((err) => - `error reading body: ${err}` - ); - throw new (Error as ErrorPolyfill)( - `graphql request to ${this.address} failed with status ${res.status}: ${body}`, - { - cause: { - response: res, - body, - }, - }, - ); - } - if (res.headers.get("content-type") != "application/json") { - throw new (Error as ErrorPolyfill)( - "unexpected content type in response", - { - cause: { - response: res, - body: await res.text().catch((err) => `error reading body: ${err}`), - }, - }, - ); - } - return await res.json() as { data: unknown; errors?: object[] }; - } - - async query>>( - query: Doc, - options?: GraphQlTransportOptions, - ): Promise> { - const { variables, doc } = this.buildGql( - Object.fromEntries( - Object.entries(query).map(( - [key, val], - ) => [key, (val as QueryNode).inner]), - ), - "query", - ); - const res = await this.fetch(doc, variables, options); - if ("errors" in res) { - throw new (Error as ErrorPolyfill)("graphql errors on response", { - cause: res.errors, - }); - } - return res.data as QueryDocOut; - } - - async mutation>>( - query: Doc, - options?: GraphQlTransportOptions, - ): Promise> { - const { variables, doc } = this.buildGql( - Object.fromEntries( - Object.entries(query).map(( - [key, val], - ) => [key, (val as MutationNode).inner]), - ), - "mutation", - ); - const res = await this.fetch(doc, variables, options); - if ("errors" in res) { - throw new (Error as ErrorPolyfill)("graphql errors on response", { - cause: res.errors, - }); - } - return res.data as QueryDocOut; - } - - prepareQuery< - T extends JsonObject, - Doc extends Record>, - >( - fun: (args: T) => Doc, - ): PreparedRequest { - return new PreparedRequest( - this.address, - this.options, - this.typeToGqlTypeMap, - fun, - "query", - ); - } - - prepareMutation< - T extends JsonObject, - Q extends Record>, - >( - fun: (args: T) => Q, - ): PreparedRequest { - return new PreparedRequest( - this.address, - this.options, - this.typeToGqlTypeMap, - fun, - "mutation", - ); - } -} - -const variablePath = Symbol("variablePath"); -const pathSeparator = "%.%"; - -class PreparedRequest< - T extends JsonObject, - Doc extends Record | MutationNode>, -> extends GraphQLTransport { - #doc: string; - #mappings: Record; - - constructor( - address: URL, - options: GraphQlTransportOptions, - typeToGqlTypeMap: Record, - fun: (args: T) => Doc, - ty: "query" | "mutation", - name: string = "", - ) { - super(address, options, typeToGqlTypeMap); - const rootId = "$root"; - const dryRunNode = fun(this.#getProxy(rootId) as unknown as T); - const { doc, variables } = this.buildGql( - Object.fromEntries( - Object.entries(dryRunNode).map(( - [key, val], - ) => [key, (val as MutationNode).inner]), - ), - ty, - name, - ); - this.#doc = doc; - this.#mappings = variables; - } - - resolveVariable( - args: T, - cur: Json, - path: string[], - idx = 0, - ): unknown { - if (idx == path.length - 1) { - return cur; - } - if (typeof cur != "object" || cur == null) { - const curPath = path.slice(0, idx); - throw new (Error as ErrorPolyfill)( - `unexpected prepard request arguments shape: item at ${curPath} is not object when trying to access ${path}`, - { - cause: { - args, - curPath, - curObj: cur, - mappings: this.#mappings, - }, - }, - ); - } - const childIdx = idx + 1; - return this.resolveVariable( - args, - (cur as JsonObject)[path[childIdx]], - path, - childIdx, - ); - } - - #getProxy(path: string): { [variablePath]: string } { - return new Proxy( - { [variablePath]: path }, - { - get: (target, prop, _reciever) => { - if (prop === variablePath) { - return path; - } - return this.#getProxy( - `${target[variablePath]}${pathSeparator}${String(prop)}`, - ); - }, - }, - ); - } - - async do(args: T, opts?: GraphQlTransportOptions): Promise< - { - [K in keyof Doc]: SelectNodeOut; - } - > { - const resolvedVariables = {} as Record; - for (const [key, val] of Object.entries(this.#mappings)) { - if (typeof val !== "object" || val == null || !(variablePath in val)) { - throw new Error("impossible"); - } - const path = (val[variablePath] as string).split(pathSeparator); - resolvedVariables[key] = this.resolveVariable(args, args, path); - } - // console.log(this.#doc, { - // resolvedVariables, - // mapping: this.#mappings, - // }); - const res = await this.fetch(this.#doc, resolvedVariables, opts); - if ("errors" in res) { - throw new (Error as ErrorPolyfill)("graphql errors on response", { - cause: res.errors, - }); - } - return res.data as QueryDocOut; - } -} - -type NodeArgValue = { - typeName: string; - value: unknown; -}; - -type NodeArgs = { - [name: string]: NodeArgValue; -}; - -export class Alias { - #aliases: Record; - constructor( - aliases: Record, - ) { - this.#aliases = aliases; - } - aliases() { - return this.#aliases; - } -} - -export function alias(aliases: Record): Alias { - return new Alias(aliases); -} - -type ScalarSelectNoArgs = - | boolean - | Alias - | null - | undefined; - -type ScalarSelectArgs = - | ArgT - | Alias - | false - | null - | undefined; - -type CompositeSelectNoArgs = - | SelectionT - | Alias - | false - | null - | undefined; - -type CompositeSelectArgs = - | [ArgT, SelectionT] - | Alias<[ArgT, SelectionT]> - | false - | undefined - | null; - -type SelectionFlags = "selectAll"; - -type Selection = { - _?: SelectionFlags; - [key: string]: - | SelectionFlags - | ScalarSelectNoArgs - | ScalarSelectArgs> - | CompositeSelectNoArgs - | CompositeSelectArgs, Selection> - | Selection; -}; - -type NodeMeta = { - subNodes?: [string, () => NodeMeta][]; - argumentTypes?: { [name: string]: string }; -}; - -export type JsonLiteral = string | number | boolean | null; -export type JsonObject = { [key: string]: Json }; -export type JsonArray = Json[]; -export type Json = JsonLiteral | JsonObject | JsonArray; - -export type DeArrayify = T extends Array ? Inner : T; - -type SelectNode = { - _phantom?: Out; - instanceName: string; - nodeName: string; - args?: NodeArgs; - subNodes?: SelectNode[]; -}; - -export class QueryNode { - constructor( - public inner: SelectNode, - ) {} -} - -export class MutationNode { - constructor( - public inner: SelectNode, - ) {} -} - -type SelectNodeOut = T extends (QueryNode | MutationNode) - ? O - : never; -type QueryDocOut = T extends - Record | MutationNode> ? { - [K in keyof T]: SelectNodeOut; - } - : never; - -function selectionToNodeSet( +function _selectionToNodeSet( selection: Selection, metas: [string, () => NodeMeta][], parentPath: string, @@ -431,6 +43,7 @@ function selectionToNodeSet( const node: SelectNode = { instanceName, nodeName }; if (argumentTypes) { + // make sure the arg is of the expected form let arg = instanceSelection; if (Array.isArray(arg)) { arg = arg[0]; @@ -443,74 +56,246 @@ function selectionToNodeSet( `is typeof ${typeof arg}`, ); } + const expectedArguments = new Map(Object.entries(argumentTypes)); - // TODO: consider logging a warning if `_` is detected incase user passes - // Selection as arg - node.args = Object.fromEntries( - Object.entries(arg) - .map(([key, value]) => { - const typeName = expectedArguments.get(key); - if (!typeName) { - throw new Error( - `unexpected argument ${key} at ${parentPath}.${instanceName}`, - ); - } - expectedArguments.delete(key); - return [key, { typeName, value }]; - }), + node.args = {}; + for (const [key, value] of Object.entries(arg)) { + const typeName = expectedArguments.get(key); + // TODO: consider logging a warning if `_` is detected incase user passes + // Selection as arg + if (!typeName) { + throw new Error( + `unexpected argument ${key} at ${parentPath}.${instanceName}`, + ); + } + expectedArguments.delete(key); + node.args[key] = { typeName, value }; + } + } + + if (subNodes) { + // sanity check selection object + let subSelections = instanceSelection; + if (argumentTypes) { + if (!Array.isArray(subSelections)) { + throw new Error( + `node at ${parentPath}.${instanceName} ` + + `is a composite that takes an argument ` + + `but selection is typeof ${typeof subSelections}`, + ); + } + subSelections = subSelections[1]; + } else if (Array.isArray(subSelections)) { + throw new Error( + `node at ${parentPath}.${instanceName} ` + + `is a composite that takes no arguments ` + + `but selection is typeof ${typeof subSelections}`, + ); + } + if (typeof subSelections != "object") { + throw new Error( + `node at ${parentPath}.${nodeName} ` + + `is a no argument composite but first element of ` + + `selection is typeof ${typeof nodeSelection}`, + ); + } + + node.subNodes = _selectionToNodeSet( + // assume it's a Selection. If it's an argument + // object, mismatch between the node desc should hopefully + // catch it + subSelections as Selection, + subNodes, + `${parentPath}.${instanceName}`, ); - // TODO: consider detecting required arguments here } - if (subNodes) { - let subSelections = instanceSelection; - if (argumentTypes) { - if (!Array.isArray(subSelections)) { - throw new Error( - `node at ${parentPath}.${instanceName} ` + - `is a composite that takes an argument ` + - `but selection is typeof ${typeof subSelections}`, - ); - } - subSelections = subSelections[1]; - } else if (Array.isArray(subSelections)) { - throw new Error( - `node at ${parentPath}.${instanceName} ` + - `is a composite that takes no arguments ` + - `but selection is typeof ${typeof subSelections}`, - ); - } - if (typeof subSelections != "object") { - throw new Error( - `node at ${parentPath}.${nodeName} ` + - `is a no argument composite but first element of ` + - `selection is typeof ${typeof nodeSelection}`, - ); - } - node.subNodes = selectionToNodeSet( - // assume it's a Selection. If it's an argument - // object, mismatch between the node desc should hopefully - // catch it - subSelections as Selection, - subNodes, - `${parentPath}.${instanceName}`, - ); - } + out.push(node); + } + } + foundNodes.delete("_"); + if (foundNodes.size > 0) { + throw new Error( + `unexpected nodes found in selection set at ${parentPath}: ${[ + ...foundNodes, + ]}`, + ); + } + return out; +} + +/* Query node types section */ + +type SelectNode<_Out = unknown> = { + nodeName: string; + instanceName: string; + args?: NodeArgs; + subNodes?: SelectNode[]; +}; + +export class QueryNode { + #inner: SelectNode; + constructor( + inner: SelectNode, + ) { + this.#inner = inner; + } + + inner() { + return this.#inner; + } +} + +export class MutationNode { + #inner: SelectNode; + constructor( + inner: SelectNode, + ) { + this.#inner = inner; + } + + inner() { + return this.#inner; + } +} + +type SelectNodeOut = T extends (QueryNode | MutationNode) + ? O + : never; +type QueryDocOut = T extends + Record | MutationNode> ? { + [K in keyof T]: SelectNodeOut; + } + : never; + +type NodeMeta = { + subNodes?: [string, () => NodeMeta][]; + argumentTypes?: { [name: string]: string }; +}; + +/* Selection types section */ + +type SelectionFlags = "selectAll"; + +type Selection = { + _?: SelectionFlags; + [key: string]: + | SelectionFlags + | ScalarSelectNoArgs + | ScalarSelectArgs> + | CompositeSelectNoArgs + | CompositeSelectArgs, Selection> + | Selection; +}; + +type ScalarSelectNoArgs = + | boolean + | Alias + | null + | undefined; + +type ScalarSelectArgs> = + | ArgT + | PlaceholderArgs + | Alias> + | false + | null + | undefined; + +type CompositeSelectNoArgs = + | SelectionT + | Alias + | false + | null + | undefined; + +type CompositeSelectArgs, SelectionT> = + | [ArgT | PlaceholderArgs, SelectionT] + | Alias<[ArgT | PlaceholderArgs, SelectionT]> + | false + | undefined + | null; + +/** + * Request multiple instances of a single node under different + * aliases. Look at {@link alias} for a functional way of instantiating + * this class. + */ +export class Alias { + #aliases: Record; + constructor( + aliases: Record, + ) { + this.#aliases = aliases; + } + aliases() { + return this.#aliases; + } +} + +/** + * Request multiple instances of a single node under different + * aliases. + */ +export function alias(aliases: Record): Alias { + return new Alias(aliases); +} - out.push(node); - } +/* Argument types section */ + +type NodeArgValue = { + typeName: string; + value: unknown; +}; + +type NodeArgs = { + [name: string]: NodeArgValue; +}; + +/** + * This object is passed to closures used for preparing requests + * ahead of time for {@link PreparedRequest}s. It allows one to + * get {@link PlaceholderValue}s that can be used in place of node + * arguments. At request time, the {@link PreparedRequest} then + * takes an object that adheres to `T` that can then be used + * to replace the placeholders. + */ +export class PreparedArgs> { + get(key: OnlyStringKeys): PlaceholderValue { + return new PlaceholderValue(key); } - foundNodes.delete("_"); - if (foundNodes.size > 0) { - throw new Error( - `unexpected nodes found in selection set at ${parentPath}: ${[ - ...foundNodes, - ]}`, - ); +} + +/** + * Placeholder values for use by {@link PreparedRequest} + */ +export class PlaceholderValue<_T> { + #key: string; + constructor(key: string) { + this.#key = key; + } + + key() { + return this.#key; } - return out; } +export type PlaceholderArgs> = { + [K in keyof T]: PlaceholderValue; +}; + +/* GraphQL section */ + +/** + * Options to be used for requests performed by {@link GraphQLTransport}. + */ +export type GraphQlTransportOptions = Omit & { + /** + * {@link fetch} implementaiton to use. Defaults to the one found in the environment + */ + fetch?: typeof fetch; +}; + function convertQueryNodeGql( node: SelectNode, variables: Map, @@ -541,9 +326,310 @@ function convertQueryNodeGql( return out; } -class QueryGraphBase { +function buildGql( + typeToGqlTypeMap: Record, + query: Record, + ty: "query" | "mutation", + name: string = "", +) { + const variables = new Map(); + + const rootNodes = Object + .entries(query) + .map(([key, node]) => { + const fixedNode = { ...node, instanceName: key }; + return convertQueryNodeGql(fixedNode, variables); + }) + .join("\n "); + + let argsRow = [...variables.entries()] + .map(([key, val]) => `$${key}: ${typeToGqlTypeMap[val.typeName]}`) + .join(", "); + if (argsRow.length > 0) { + // graphql doesn't like empty parentheses so we only + // add them if there are args + argsRow = `(${argsRow})`; + } + + const doc = `${ty} ${name}${argsRow} { + ${rootNodes} +}`; + return { + doc, + variables: Object.fromEntries( + [...variables.entries()] + .map(([key, val]) => [key, val.value]), + ), + }; +} + +async function fetchGql( + addr: URL, + doc: string, + variables: Record, + options: GraphQlTransportOptions, +) { + // console.log(doc, variables); + const fetchImpl = options.fetch ?? fetch; + const res = await fetchImpl(addr, { + ...options, + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + ...options.headers ?? {}, + }, + body: JSON.stringify({ + query: doc, + variables, + }), + }); + if (!res.ok) { + const body = await res.text().catch((err) => `error reading body: ${err}`); + throw new (Error as ErrorPolyfill)( + `graphql request to ${addr} failed with status ${res.status}: ${body}`, + { + cause: { + response: res, + body, + }, + }, + ); + } + if (res.headers.get("content-type") != "application/json") { + throw new (Error as ErrorPolyfill)( + "unexpected content type in response", + { + cause: { + response: res, + body: await res.text().catch((err) => `error reading body: ${err}`), + }, + }, + ); + } + return await res.json() as { data: unknown; errors?: object[] }; +} + +/** + * Access the typegraph over it's exposed GraphQL API. + */ +export class GraphQLTransport { + constructor( + public address: URL, + public options: GraphQlTransportOptions, + private typeToGqlTypeMap: Record, + ) { + } + + async #request( + doc: string, + variables: Record, + options?: GraphQlTransportOptions, + ) { + const res = await fetchGql(this.address, doc, variables, { + ...this.options, + ...options, + }); + if ("errors" in res) { + throw new (Error as ErrorPolyfill)("graphql errors on response", { + cause: res.errors, + }); + } + return res.data; + } + + /** + * Make a query request to the typegraph. + */ + async query>>( + query: Doc, + { options, name = "" }: { + options?: GraphQlTransportOptions; + name?: string; + } = {}, + ): Promise> { + const { variables, doc } = buildGql( + this.typeToGqlTypeMap, + Object.fromEntries( + Object.entries(query).map(( + [key, val], + ) => [key, (val as QueryNode).inner()]), + ), + "query", + name, + ); + return await this.#request(doc, variables, options) as QueryDocOut; + } + + /** + * Make a mutation request to the typegraph. + */ + async mutation>>( + query: Doc, + { options, name = "" }: { + options?: GraphQlTransportOptions; + name?: string; + } = {}, + ): Promise> { + const { variables, doc } = buildGql( + this.typeToGqlTypeMap, + Object.fromEntries( + Object.entries(query).map(( + [key, val], + ) => [key, (val as MutationNode).inner()]), + ), + "mutation", + name, + ); + return await this.#request(doc, variables, options) as QueryDocOut; + } + + /** + * Prepare an ahead of time query {@link PreparedRequest}. + */ + prepareQuery< + T extends JsonObject, + Doc extends Record>, + >( + fun: (args: PreparedArgs) => Doc, + { name = "" }: { name?: string } = {}, + ): PreparedRequest { + return new PreparedRequest( + this.address, + this.options, + this.typeToGqlTypeMap, + fun, + "query", + name, + ); + } + + /** + * Prepare an ahead of time mutation {@link PreparedRequest}. + */ + prepareMutation< + T extends JsonObject, + Q extends Record>, + >( + fun: (args: PreparedArgs) => Q, + { name = "" }: { name?: string } = {}, + ): PreparedRequest { + return new PreparedRequest( + this.address, + this.options, + this.typeToGqlTypeMap, + fun, + "mutation", + name, + ); + } +} + +/** + * Prepares the GraphQL string ahead of time and allows re-use + * avoid the compute and garbage overhead of re-building it for + * repeat queries. + */ +export class PreparedRequest< + T extends JsonObject, + Doc extends Record | MutationNode>, +> { + public doc: string; + #mappings: Record; + + constructor( + private address: URL, + private options: GraphQlTransportOptions, + typeToGqlTypeMap: Record, + fun: (args: PreparedArgs) => Doc, + ty: "query" | "mutation", + name: string = "", + ) { + const args = new PreparedArgs(); + const dryRunNode = fun(args); + const { doc, variables } = buildGql( + typeToGqlTypeMap, + Object.fromEntries( + Object.entries(dryRunNode).map(( + [key, val], + ) => [key, (val as MutationNode).inner()]), + ), + ty, + name, + ); + this.doc = doc; + this.#mappings = variables; + } + + resolveVariables( + args: T, + mappings: Record, + ) { + const resolvedVariables = {} as Record; + for (const [key, val] of Object.entries(mappings)) { + if (val instanceof PlaceholderValue) { + resolvedVariables[key] = args[val.key()]; + } else if (typeof val == "object" && val != null) { + this.resolveVariables(args, val as JsonObject); + } else { + resolvedVariables[key] = val; + } + } + return resolvedVariables; + } + + /** + * Execute the prepared request. + */ + async perform(args: T, opts?: GraphQlTransportOptions): Promise< + { + [K in keyof Doc]: SelectNodeOut; + } + > { + const resolvedVariables = this.resolveVariables(args, this.#mappings); + // console.log(this.doc, { + // resolvedVariables, + // mapping: this.#mappings, + // }); + const res = await fetchGql( + this.address, + this.doc, + resolvedVariables, + { + ...this.options, + ...opts, + }, + ); + if ("errors" in res) { + throw new (Error as ErrorPolyfill)("graphql errors on response", { + cause: res.errors, + }); + } + return res.data as QueryDocOut; + } +} + +/* Util types section */ + +type OnlyStringKeys> = { + [K in keyof T]: K extends string ? K : never; +}[keyof T]; + +type JsonLiteral = string | number | boolean | null; +type JsonObject = { [key: string]: Json }; +type JsonArray = Json[]; +type Json = JsonLiteral | JsonObject | JsonArray; + +type ErrorPolyfill = new (msg: string, payload: unknown) => Error; + +/* QueryGraph section */ + +class _QueryGraphBase { constructor(private typeNameMapGql: Record) {} + /** + * Get the {@link GraphQLTransport} for the typegraph. + */ graphql(addr: URL | string, options?: GraphQlTransportOptions) { return new GraphQLTransport( new URL(addr), @@ -570,29 +656,21 @@ const nodeMetas = { }; }, - Func24(): NodeMeta { + Func27(): NodeMeta { return { ...nodeMetas.Post(), }; }, - Func26(): NodeMeta { + Func25(): NodeMeta { return { ...nodeMetas.scalar(), - argumentTypes: { - id: "String4", - slug: "String1", - title: "String1", - }, }; }, - Func28(): NodeMeta { + Func24(): NodeMeta { return { ...nodeMetas.Post(), - argumentTypes: { - id: "String13", - }, }; }, User(): NodeMeta { @@ -611,15 +689,23 @@ const nodeMetas = { }; }, - Func27(): NodeMeta { + Func28(): NodeMeta { return { ...nodeMetas.Post(), + argumentTypes: { + id: "String13", + }, }; }, - Func25(): NodeMeta { + Func26(): NodeMeta { return { ...nodeMetas.scalar(), + argumentTypes: { + id: "String4", + slug: "String1", + title: "String1", + }, }; }, }; @@ -629,9 +715,6 @@ export type Post = { slug: string; title: string; }; -export type Object21 = { - id: string; -}; export type StringEmail = string; export type Post7 = Array; export type User = { @@ -639,6 +722,9 @@ export type User = { email: StringEmail; posts: Post7; }; +export type Object21 = { + id: string; +}; export type PostSelections = { _?: SelectionFlags; @@ -653,7 +739,7 @@ export type UserSelections = { posts?: CompositeSelectNoArgs; }; -export class QueryGraph extends QueryGraphBase { +export class QueryGraph extends _QueryGraphBase { constructor() { super({ "String4": "Any", @@ -663,7 +749,7 @@ export class QueryGraph extends QueryGraphBase { } getUser(select: UserSelections) { - const inner = selectionToNodeSet( + const inner = _selectionToNodeSet( { "getUser": select }, [["getUser", nodeMetas.Func23]], "$q", @@ -671,7 +757,7 @@ export class QueryGraph extends QueryGraphBase { return new QueryNode(inner) as QueryNode; } getPosts(select: PostSelections) { - const inner = selectionToNodeSet( + const inner = _selectionToNodeSet( { "getPosts": select }, [["getPosts", nodeMetas.Func24]], "$q", @@ -679,35 +765,38 @@ export class QueryGraph extends QueryGraphBase { return new QueryNode(inner) as QueryNode; } scalarNoArgs() { - const inner = selectionToNodeSet( + const inner = _selectionToNodeSet( { "scalarNoArgs": true }, [["scalarNoArgs", nodeMetas.Func25]], "$q", )[0]; return new QueryNode(inner) as QueryNode; } - scalarArgs(args: Post) { - const inner = selectionToNodeSet( + scalarArgs(args: Post | PlaceholderArgs) { + const inner = _selectionToNodeSet( { "scalarArgs": args }, [["scalarArgs", nodeMetas.Func26]], "$q", )[0]; - return new QueryNode(inner) as QueryNode; + return new MutationNode(inner) as MutationNode; } compositeNoArgs(select: PostSelections) { - const inner = selectionToNodeSet( + const inner = _selectionToNodeSet( { "compositeNoArgs": select }, [["compositeNoArgs", nodeMetas.Func27]], "$q", )[0]; - return new QueryNode(inner) as QueryNode; + return new MutationNode(inner) as MutationNode; } - compositeArgs(args: Object21, select: PostSelections) { - const inner = selectionToNodeSet( + compositeArgs( + args: Object21 | PlaceholderArgs, + select: PostSelections, + ) { + const inner = _selectionToNodeSet( { "compositeArgs": [args, select] }, [["compositeArgs", nodeMetas.Func28]], "$q", )[0]; - return new QueryNode(inner) as QueryNode; + return new MutationNode(inner) as MutationNode; } } diff --git a/typegate/tests/metagen/typegraphs/sample/ts/main.ts b/typegate/tests/metagen/typegraphs/sample/ts/main.ts index 3c17bc4196..e8abfc47e1 100644 --- a/typegate/tests/metagen/typegraphs/sample/ts/main.ts +++ b/typegate/tests/metagen/typegraphs/sample/ts/main.ts @@ -1,7 +1,7 @@ // Copyright Metatype OÜ, licensed under the Elastic License 2.0. // SPDX-License-Identifier: Elastic-2.0 -import { alias, QueryGraph } from "./client.ts"; +import { alias, PreparedArgs, QueryGraph } from "./client.ts"; const api1 = new QueryGraph(); @@ -9,8 +9,7 @@ const gqlClient = api1.graphql( `http://localhost:${Deno.env.get("TG_PORT")}/sample`, ); -const res = await gqlClient.query({ - // syntax very similar to typegraph definition +const preparedQ = gqlClient.prepareQuery(() => ({ user: api1.getUser({ _: "selectAll", posts: alias({ @@ -21,6 +20,53 @@ const res = await gqlClient.query({ posts: api1.getPosts({ _: "selectAll" }), scalarNoArgs: api1.scalarNoArgs(), +})); + +const preparedM = gqlClient.prepareMutation(( + args: PreparedArgs<{ + id: string; + slug: string; + title: string; + }>, +) => ({ + scalarArgs: api1.scalarArgs({ + id: args.get("id"), + slug: args.get("slug"), + title: args.get("title"), + }), + compositeNoArgs: api1.compositeNoArgs({ + _: "selectAll", + }), + compositeArgs: api1.compositeArgs({ + id: args.get("id"), + }, { + _: "selectAll", + }), +})); + +const res1 = await preparedQ.perform({}); +const res1a = await preparedQ.perform({}); + +const res2 = await preparedM.perform({ + id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", + slug: "s", + title: "t", +}); + +const res3 = await gqlClient.query({ + user: api1.getUser({ + _: "selectAll", + posts: alias({ + post1: { id: true, slug: true, title: true }, + post2: { _: "selectAll", id: false }, + }), + }), + posts: api1.getPosts({ _: "selectAll" }), + + scalarNoArgs: api1.scalarNoArgs(), +}); + +const res4 = await gqlClient.mutation({ scalarArgs: api1.scalarArgs({ id: "94be5420-8c4a-4e67-b4f4-e1b2b54832a2", slug: "", @@ -35,10 +81,5 @@ const res = await gqlClient.query({ _: "selectAll", }), }); -console.log(JSON.stringify(res)); - -/* const prepared = gqlClient.prepareQuery((args: { filter: string }) => ({ - posts2: api1.getPosts({ filter: args.filter }, { _: "selectAll" }), -})); -const { posts2 } = await prepared.do({ filter: "hey" }); */ +console.log(JSON.stringify([res1, res1a, res2, res3, res4]));