From a2c2a8084225bb6cd9454e948471eaf6f7b3f086 Mon Sep 17 00:00:00 2001 From: Natoandro Date: Sat, 24 Feb 2024 09:32:55 +0300 Subject: [PATCH] feat!: Nested context query (#595) - Revert context flattening - Enable jsonpath-like key to access nested object fields or array items on the context. #### Migration notes If you access the context directly in your application (through the token), access to nested fields shall be updated. E.g. the expression `context["profile.id"]` have to turned to `context.profile.id`. ### Checklist - [x] The change come with new or modified tests - [x] Hard-to-understand functions have explanatory comments - [ ] End-user documentation is updated to reflect the change --- typegate/deno.lock | 8 - typegate/src/engine/planner/args.ts | 24 +- .../engine/planner/parameter_transformer.ts | 95 +++++- .../typecheck/inline_validators/number.ts | 4 +- .../typecheck/inline_validators/string.ts | 2 +- typegate/src/libs/jsonpath.ts | 272 ++++++++++++++++++ typegate/src/services/auth/mod.ts | 7 +- .../src/services/auth/protocols/oauth2.ts | 8 +- typegate/tests/auth/auth_test.ts | 8 +- typegate/tests/injection/injection_test.ts | 115 +++++++- typegate/tests/injection/nested_context.py | 25 ++ typegate/tests/params/apply_nested_context.py | 27 ++ typegate/tests/params/apply_test.ts | 108 +++++++ typegraph/core/src/lib.rs | 2 + typegraph/core/wit/typegraph.wit | 1 + typegraph/node/sdk/src/policy.ts | 25 +- typegraph/python/typegraph/policy.py | 7 +- 17 files changed, 683 insertions(+), 55 deletions(-) create mode 100644 typegate/src/libs/jsonpath.ts create mode 100644 typegate/tests/injection/nested_context.py create mode 100644 typegate/tests/params/apply_nested_context.py diff --git a/typegate/deno.lock b/typegate/deno.lock index f7d7b0d2f..2a87c2194 100644 --- a/typegate/deno.lock +++ b/typegate/deno.lock @@ -1106,13 +1106,5 @@ "https://raw.githubusercontent.com/metatypedev/metatype/feat/MET-250/refactor-ffi/typegate/src/typegraph/visitor.ts": "854f2dd1adadc62ea2050f6e0f293c88f76d4feefb7620bcc490049fb8967043", "https://raw.githubusercontent.com/metatypedev/metatype/feat/MET-250/refactor-ffi/typegate/src/types.ts": "1857e6bf96b0642e15352e10dd4e175c4983edc421868ae0158ce271e075926d", "https://raw.githubusercontent.com/metatypedev/metatype/feat/MET-250/refactor-ffi/typegate/src/utils.ts": "8a34944dc326d1759c67fcdd4a714f99838d5eac040773bcdb7287a00118923b" - }, - "workspace": { - "packageJson": { - "dependencies": [ - "npm:chance@^1.1.11", - "npm:yarn@^1.22.19" - ] - } } } diff --git a/typegate/src/engine/planner/args.ts b/typegate/src/engine/planner/args.ts index bbd8651cf..96771dc2e 100644 --- a/typegate/src/engine/planner/args.ts +++ b/typegate/src/engine/planner/args.ts @@ -39,6 +39,7 @@ import { compileParameterTransformer, defaultParameterTransformer, } from "./parameter_transformer.ts"; +import { QueryFunction as JsonPathQuery } from "../../libs/jsonpath.ts"; class MandatoryArgumentError extends Error { constructor(argDetails: string) { @@ -657,20 +658,23 @@ class ArgumentCollector { return () => this.tg.parseSecret(typ, secretName); } case "context": { - const contextKey = selectInjection(injection.data, this.effect); - if (contextKey == null) { + const contextPath = selectInjection(injection.data, this.effect); + if (contextPath == null) { return null; } - this.deps.context.add(contextKey); + this.deps.context.add(contextPath); + const queryContext = JsonPathQuery.create(contextPath, { + strict: typ.type !== Type.OPTIONAL, + rootPath: "", + }) + .asFunction(); return ({ context }) => { - const { [contextKey]: value = null } = context; - if (value === null && typ.type != Type.OPTIONAL) { - const suggestions = Object.keys(context).join(", "); - throw new BadContext( - `Non optional injection '${contextKey}' was not found in the context, available context keys are ${suggestions}`, - ); + try { + return queryContext(context) ?? null; + } catch (e) { + const msg = e.message; + throw new BadContext("Error while querying context: " + msg); } - return value; }; } diff --git a/typegate/src/engine/planner/parameter_transformer.ts b/typegate/src/engine/planner/parameter_transformer.ts index 988bc8ea3..78ff68849 100644 --- a/typegate/src/engine/planner/parameter_transformer.ts +++ b/typegate/src/engine/planner/parameter_transformer.ts @@ -1,6 +1,7 @@ // Copyright Metatype OÜ, licensed under the Elastic License 2.0. // SPDX-License-Identifier: Elastic-2.0 +import { QueryFn, QueryFunction } from "../../libs/jsonpath.ts"; import { TypeGraph } from "../../typegraph/mod.ts"; import { Type } from "../../typegraph/type_node.ts"; import { ParameterTransformNode } from "../../typegraph/types.ts"; @@ -15,8 +16,8 @@ import { generateStringValidator } from "../typecheck/inline_validators/string.t export type TransformParamsInput = { args: Record; - context: Record; parent: Record; + context: Record; }; export function defaultParameterTransformer(input: TransformParamsInput) { @@ -27,26 +28,83 @@ export type TransformParams = { (input: TransformParamsInput): Record; }; +type CompiledTransformerInput = { + args: Record; + parent: Record; + getContext: ContextQuery; +}; + +type CompiledTransformer = { + (input: CompiledTransformerInput): Record; +}; + export function compileParameterTransformer( typegraph: TypeGraph, parentProps: Record, transformerTreeRoot: ParameterTransformNode, ): TransformParams { const ctx = new TransformerCompilationContext(typegraph, parentProps); - const fnBody = ctx.compile(transformerTreeRoot); - const fn = new Function("input", fnBody) as TransformParams; - return (input) => { - const res = fn(input); + const { fnBody, deps } = ctx.compile(transformerTreeRoot); + const fn = new Function("input", fnBody) as CompiledTransformer; + return ({ args, context, parent }) => { + const getContext = compileContextQueries(deps.contexts)(context); + const res = fn({ args, getContext, parent }); return res; }; } +type Dependencies = { + contexts: { + strictMode: Set; + nonStrictMode: Set; + }; +}; + +type ContextQuery = (path: string, options: { strict: boolean }) => unknown; + +function compileContextQueries(contexts: Dependencies["contexts"]) { + return (context: Record): ContextQuery => { + const strictMode = new Map(); + const nonStrictMode = new Map(); + + for (const path of contexts.strictMode) { + strictMode.set( + path, + QueryFunction.create(path, { strict: true }).asFunction(), + ); + } + + for (const path of contexts.nonStrictMode) { + nonStrictMode.set( + path, + QueryFunction.create(path, { strict: false }).asFunction(), + ); + } + + return (path, options) => { + const fn = options.strict + ? strictMode.get(path) + : nonStrictMode.get(path); + if (!fn) { + throw new Error(`Unknown context query: ${path}`); + } + return fn(context); + }; + }; +} + class TransformerCompilationContext { #tg: TypeGraph; #parentProps: Record; #path: string[] = []; #latestVarIndex = 0; #collector: string[] = []; + #dependencies: Dependencies = { + contexts: { + strictMode: new Set(), + nonStrictMode: new Set(), + }, + }; constructor(typegraph: TypeGraph, parentProps: Record) { this.#tg = typegraph; @@ -55,15 +113,28 @@ class TransformerCompilationContext { #reset() { this.#collector = [ - "const { args, context, parent } = input;\n", + "const { args, parent, getContext } = input;\n", ]; + this.#dependencies = { + contexts: { + strictMode: new Set(), + nonStrictMode: new Set(), + }, + }; } compile(rootNode: ParameterTransformNode) { this.#reset(); const varName = this.#compileNode(rootNode); this.#collector.push(`return ${varName};`); - return this.#collector.join("\n"); + const res = { + fnBody: this.#collector.join("\n"), + deps: this.#dependencies, + }; + + this.#reset(); + + return res; } #compileNode(node: ParameterTransformNode) { @@ -165,7 +236,13 @@ class TransformerCompilationContext { typeNode = this.#tg.type(typeNode.item); optional = true; } - this.#collector.push(`const ${varName} = context[${JSON.stringify(key)}];`); + + const opts = `{ strict: ${!optional} }`; + this.#collector.push( + `const ${varName} = getContext(${JSON.stringify(key)}, ${opts});`, + ); + const mode = optional ? "nonStrictMode" : "strictMode"; + this.#dependencies.contexts[mode].add(key); const path = this.#path.join("."); @@ -270,6 +347,6 @@ class TransformerCompilationContext { } #createVarName() { - return `var${++this.#latestVarIndex}`; + return `_var${++this.#latestVarIndex}`; } } diff --git a/typegate/src/engine/typecheck/inline_validators/number.ts b/typegate/src/engine/typecheck/inline_validators/number.ts index 7ddfcb251..9d14dbcd7 100644 --- a/typegate/src/engine/typecheck/inline_validators/number.ts +++ b/typegate/src/engine/typecheck/inline_validators/number.ts @@ -22,8 +22,8 @@ export function generateNumberValidator( ): string[] { return [ check( - `typeof ${varName} === "number && !isNan(${varName})"`, - `"Expected number at ${path}"`, + `typeof ${varName} === "number" && !isNaN(${varName})`, + `"Expected number at '${path}'"`, ), ...generateConstraintValidatorsFor(numberConstraints, typeNode), ]; diff --git a/typegate/src/engine/typecheck/inline_validators/string.ts b/typegate/src/engine/typecheck/inline_validators/string.ts index 4888071b9..42d1f79be 100644 --- a/typegate/src/engine/typecheck/inline_validators/string.ts +++ b/typegate/src/engine/typecheck/inline_validators/string.ts @@ -42,7 +42,7 @@ export function generateStringValidator( `const ${validatorName} = context.formatValidators[${format}];`, check( `${varName} != null`, - `Unknown format: ${format}`, + `'Unknown format: ${typeNode.format}'`, ), check( `${validatorName}(${varName})`, diff --git a/typegate/src/libs/jsonpath.ts b/typegate/src/libs/jsonpath.ts new file mode 100644 index 000000000..b75dc1a22 --- /dev/null +++ b/typegate/src/libs/jsonpath.ts @@ -0,0 +1,272 @@ +// Copyright Metatype OÜ, licensed under the Elastic License 2.0. +// SPDX-License-Identifier: Elastic-2.0 + +/** + * JsonPath query compiler: jsonpath query to javascript function; + * to be used for getting context value. + * + * This only support a subset of the jsonpath spec (https://goessner.net/articles/JsonPath/). + * The following are the supported features: + * - Object property access with dot notation or bracket notation + * - Array index access with bracket notation + * Additionally we support omitting the leading "$" character or the leading "$." characters. + */ + +export type ArrayIndex = { + type: "array"; + index: number; +}; + +export type ObjectKey = { + type: "object"; + key: string; +}; + +export type PathSegment = ArrayIndex | ObjectKey; + +function parsePath(path: string): PathSegment[] { + const parser = new PathParser(path); + const res = parser.parse(); + return res; +} + +class PathParser { + #currentIndex = 0; + #segments: PathSegment[] = []; + #path: string; + + private static getEffectivePath(path: string) { + if (path[0] === "$") { + return path.slice(1); + } + if (path[0] === "." || path[0] === "[") { + return path; + } + return "." + path; + } + + constructor(path: string) { + this.#path = PathParser.getEffectivePath(path); + } + parse(): PathSegment[] { + while (this.#nextSegment()); + return this.#segments; + } + + #nextSegment(): boolean { + if (this.#currentIndex === this.#path.length) { + return false; + } + + const firstChar = this.#path[this.#currentIndex]; + if (firstChar === ".") { + this.#currentIndex += 1; + return this.#parseKey(); + } + + if (firstChar === "[") { + this.#currentIndex += 1; + return this.#parseIndex(); + } + + throw new Error(`Unexpected character: ${firstChar}`); + } + + #parseKey(): boolean { + let end = this.#currentIndex; + for (; end < this.#path.length; end++) { + if (this.#path[end] === "." || this.#path[end] === "[") { + break; + } + } + + this.#segments.push({ + type: "object", + key: this.#path.slice(this.#currentIndex, end), + }); + this.#currentIndex = end; + return true; + } + + #parseIndex(): boolean { + const firstChar = this.#path[this.#currentIndex]; + if (firstChar === '"') { + return this.#parseStringIndex('"'); + } + if (firstChar === "'") { + return this.#parseStringIndex("'"); + } + return this.#parseNumberIndex(); + } + + #parseStringIndex(quote: string): boolean { + let end = this.#currentIndex + 1; + for (; end < this.#path.length; end++) { + if (this.#path[end] === quote && this.#path[end - 1] !== "\\") { + const key = JSON.parse(this.#path.slice(this.#currentIndex, end + 1)); + if (typeof key !== "string") { + throw new Error(`Unexpected: Invalid string index`); + } + this.#currentIndex = end; + return this.#append({ type: "object", key }, 2); // closing quote and bracket + } + } + + const unterminated = this.#path.slice(this.#currentIndex); + const position = this.#currentIndex; + throw new Error( + `Unterminated string index: '${unterminated}' at ${position}`, + ); + } + + #parseNumberIndex(): boolean { + let end = this.#currentIndex; + for (; end < this.#path.length; end++) { + if (this.#path[end] === "]") { + const index = Number(this.#path.slice(this.#currentIndex, end)); + if (Number.isNaN(index)) { + throw new Error( + `Invalid number index: ${ + this.#path.slice(this.#currentIndex, end) + }`, + ); + } + this.#currentIndex = end; + return this.#append({ type: "array", index }, 1); + } + } + + const unterminated = this.#path.slice(this.#currentIndex); + const position = this.#currentIndex; + throw new Error( + `Unterminated number index: '${unterminated}' at ${position}`, + ); + } + + #append(segment: PathSegment, increment = 1) { + this.#segments.push(segment); + this.#currentIndex += increment; + return true; + } +} + +function stringifySegment(segment: PathSegment): string { + if (segment.type === "array") { + return `[${segment.index}]`; + } + if (/^\w+$/.test(segment.key)) { + return `.${segment.key}`; + } + return `[${JSON.stringify(segment.key)}]`; +} + +export type QueryFn = (value: unknown) => unknown; + +export type JsonPathQueryOptions = { + strict: boolean; + rootPath?: string; +}; + +export class QueryFunction { + private constructor(private code: string) {} + + static create(path: string, options: JsonPathQueryOptions) { + const compiler = new QueryFnCompiler(path, options); + const body = compiler.compile(); + return new QueryFunction(body); + } + + asFunction() { + return new Function("initialValue", this.code) as QueryFn; + } + + asFunctionDef(name: string) { + if (/^[A-Za-z_]\w*$/.test(name)) { + return `function ${name}(initialValue) {\n${this.code}\n}`; + } else { + throw new Error(`Invalid function name: ${name}`); + } + } +} + +// if not strict, return undefined for unresolved path; otherwise throw... +class QueryFnCompiler { + #lines: string[] = []; + #path: PathSegment[]; + #currentIndex = 0; + + constructor(path: string, private options: JsonPathQueryOptions) { + this.#path = parsePath(path); + } + + compile() { + this.#lines.push("let value = initialValue;"); + for (; this.#currentIndex < this.#path.length; this.#currentIndex++) { + const segment = this.#path[this.#currentIndex]; + switch (segment.type) { + case "array": + this.#compileArraySegment(segment); + break; + case "object": + this.#compileObjectSegment(segment); + break; + } + } + this.#lines.push("return value;"); + const compiled = this.#lines.join("\n"); + this.#lines = []; + return compiled; + } + + #compileArraySegment(segment: ArrayIndex) { + if (this.options.strict) { + this.#lines.push(`if (!Array.isArray(value)) {`); + const error = `Expected an array at \`${this.#currentPath}\``; + this.#lines.push(` throw new Error(${JSON.stringify(error)});`); + this.#lines.push(`}`); + } else { + this.#lines.push(`if (!Array.isArray(value)) return undefined;`); + } + this.#lines.push(`value = value[${segment.index}];`); + + if (this.options.strict) { + this.#lines.push(`if (value === undefined) {`); + const error2 = + `Index ${segment.index} out of range at \`${this.#currentPath}\``; + this.#lines.push(` throw new Error(${JSON.stringify(error2)});`); + this.#lines.push(`}`); + } + } + + #compileObjectSegment(segment: ObjectKey) { + if (this.options.strict) { + this.#lines.push(`if (typeof value !== "object" || value === null) {`); + const error = `Expected an object at \`${this.#currentPath}\``; + this.#lines.push(` throw new Error(${JSON.stringify(error)});`); + this.#lines.push(`}`); + } else { + this.#lines.push( + `if (typeof value !== "object" || value === null) return undefined;`, + ); + } + this.#lines.push(`value = value[${JSON.stringify(segment.key)}];`); + if (this.options.strict) { + const error2 = + `Property '${segment.key}' not found at \`${this.#currentPath}\``; + this.#lines.push(`if (value === undefined) {`); + this.#lines.push(` throw new Error(${JSON.stringify(error2)});`); + this.#lines.push(`}`); + } + } + + get #currentPath() { + const rootPath = this.options.rootPath ?? "$"; + return rootPath + this.#path.slice(0, this.#currentIndex) + .map(stringifySegment) + .join(""); + } + + get #currentSegment() { + return stringifySegment(this.#path[this.#currentIndex]); + } +} diff --git a/typegate/src/services/auth/mod.ts b/typegate/src/services/auth/mod.ts index 852f4767e..d7675213e 100644 --- a/typegate/src/services/auth/mod.ts +++ b/typegate/src/services/auth/mod.ts @@ -50,17 +50,14 @@ export function initAuth( } } -export type ProfileClaims = { - [key: `profile.${string}`]: unknown; -}; - export type JWTClaims = { provider: string; accessToken: string; refreshToken: string; refreshAt: number; + profile: null | Record; scope?: string[]; -} & ProfileClaims; +}; export async function ensureJWT( request: Request, diff --git a/typegate/src/services/auth/protocols/oauth2.ts b/typegate/src/services/auth/protocols/oauth2.ts index acecff669..d7c3be906 100644 --- a/typegate/src/services/auth/protocols/oauth2.ts +++ b/typegate/src/services/auth/protocols/oauth2.ts @@ -4,7 +4,7 @@ import config from "../../../config.ts"; import { OAuth2Client, OAuth2ClientConfig, Tokens } from "oauth2_client"; import { encrypt, randomUUID, signJWT, verifyJWT } from "../../../crypto.ts"; -import { AdditionalAuthParams, JWTClaims, ProfileClaims } from "../mod.ts"; +import { AdditionalAuthParams, JWTClaims } from "../mod.ts"; import { getLogger } from "../../../log.ts"; import { SecretManager } from "../../../typegraph/mod.ts"; import { @@ -21,7 +21,6 @@ import { generateValidator, generateWeakValidator, } from "../../../engine/typecheck/input.ts"; -import { mapKeys } from "std/collections/map_keys.ts"; import { TokenMiddlewareOutput } from "./protocol.ts"; const logger = getLogger(import.meta); @@ -313,9 +312,6 @@ export class OAuth2Auth extends Protocol { request: Request, ): Promise { const profile = await this.getProfile(token, request); - const profileClaims: ProfileClaims = profile - ? mapKeys(profile, (k) => `profile.${k}`) - : {}; const payload: JWTClaims = { provider: this.authName, accessToken: token.accessToken, @@ -325,7 +321,7 @@ export class OAuth2Auth extends Protocol { (token.expiresIn ?? config.jwt_refresh_duration_sec), ), scope: token.scope, - ...profileClaims, + profile, }; return await signJWT(payload, config.jwt_max_duration_sec); } diff --git a/typegate/tests/auth/auth_test.ts b/typegate/tests/auth/auth_test.ts index 83e7bcf6e..4fc3e9202 100644 --- a/typegate/tests/auth/auth_test.ts +++ b/typegate/tests/auth/auth_test.ts @@ -177,7 +177,7 @@ Meta.test("Auth", async (t) => { const { token } = JSON.parse(await decrypt(cook!)); const claims = await verifyJWT(token) as JWTClaims; assertEquals(claims.accessToken, accessToken); - assertEquals(claims["profile.id"], id); + assertEquals(claims.profile?.id, id); assertEquals(await decrypt(claims.refreshToken as string), refreshToken); }); @@ -262,7 +262,7 @@ Meta.test("Auth", async (t) => { accessToken: "a1", refreshToken: "r1", refreshAt: new Date().valueOf() + 10, - "profile.id": 123, + profile: { id: 123 }, }; const jwt = await signJWT(claims, 10); await gql` @@ -292,7 +292,7 @@ Meta.test("Auth", async (t) => { accessToken: "a1", refreshToken, refreshAt: Math.floor(new Date().valueOf() / 1000), - "profile.id": 123, + profile: { id: 123 }, }; const jwt = await signJWT(claims, 10); await sleep(1); @@ -359,7 +359,7 @@ Meta.test("Auth", async (t) => { accessToken: "a1", refreshToken: "r1", refreshAt: Math.floor(new Date().valueOf() / 1000), - "profile.id": 123, + profile: { id: 123 }, }; const jwt = await signJWT(claims, 10); await sleep(1); diff --git a/typegate/tests/injection/injection_test.ts b/typegate/tests/injection/injection_test.ts index 48bcd56fa..b2696a883 100644 --- a/typegate/tests/injection/injection_test.ts +++ b/typegate/tests/injection/injection_test.ts @@ -43,7 +43,7 @@ Meta.test("Injected values", async (t) => { } } ` - .expectErrorContains("'userId' was not found in the context") + .expectErrorContains("'userId' not found at ``") .on(e); }); @@ -299,3 +299,116 @@ Meta.test("Deno: value injection", async (t) => { }); unfreezeDate(); }); + +Meta.test("Injection from nested context", async (t) => { + const e = await t.engine("injection/nested_context.py"); + + await t.should("access injected nested context", async () => { + await gql` + query { + injectedId { + id + } + } + ` + .withContext({ profile: { id: 123 } }) + .expectData({ + injectedId: { + id: 123, + }, + }) + .on(e); + }); + + await t.should( + "access injected nested context with array index", + async () => { + await gql` + query { + secondProfileData { + second + } + } + ` + .withContext({ + profile: { + data: [1234, 5678], + }, + }) + .expectData({ + secondProfileData: { + second: 5678, + }, + }) + .on(e); + }, + ); + + await t.should( + "access injected nested context with custom key", + async () => { + await gql` + query { + customKey { + custom + } + } + ` + .withContext({ + profile: { + "custom key": 123, + }, + }) + .expectData({ + customKey: { + custom: 123, + }, + }) + .on(e); + }, + ); + + await t.should( + "fail for invalid context", + async () => { + await gql` + query { + secondProfileData { + second + } + } + ` + .withContext({ + profile: { + "invalid key": 123, + }, + }) + .expectErrorContains("Property 'data' not found at `.profile`") + .on(e); + }, + ); + + await t.should( + "work with missing context on optional type", + async () => { + await gql` + query { + optional { + optional + } + } + ` + .withContext({ + profile: { + id: 1234, + }, + }) + .expectData({ + optional: { + optional: null, + }, + }) + .on(e); + }, + ); +}); diff --git a/typegate/tests/injection/nested_context.py b/typegate/tests/injection/nested_context.py new file mode 100644 index 000000000..207323e90 --- /dev/null +++ b/typegate/tests/injection/nested_context.py @@ -0,0 +1,25 @@ +from typegraph import typegraph, Policy, t, Graph +from typegraph.runtimes import DenoRuntime + + +@typegraph() +def test_nested_context(g: Graph): + deno = DenoRuntime() + has_profile = Policy.context("profile") + + g.expose( + has_profile, + injectedId=deno.identity( + # TODO validate the path against the profiler result?? + t.struct({"id": t.integer().from_context("profile.id")}) + ), + secondProfileData=deno.identity( + t.struct({"second": t.integer().from_context("profile.data[1]")}) + ), + customKey=deno.identity( + t.struct({"custom": t.integer().from_context('profile["custom key"]')}) + ), + optional=deno.identity( + t.struct({"optional": t.email().optional().from_context("profile.email")}) + ), + ) diff --git a/typegate/tests/params/apply_nested_context.py b/typegate/tests/params/apply_nested_context.py new file mode 100644 index 000000000..1335bdbac --- /dev/null +++ b/typegate/tests/params/apply_nested_context.py @@ -0,0 +1,27 @@ +from typegraph import typegraph, t, Graph, Policy +from typegraph.runtimes import DenoRuntime + + +@typegraph() +def apply_nested_context(g: Graph): + deno = DenoRuntime() + has_profile = Policy.context("profile") + + g.expose( + has_profile, + simple=deno.identity(t.struct({"id": t.integer()})).apply( + {"id": g.from_context("profile.id")} + ), + customKey=deno.identity(t.struct({"custom": t.string()})).apply( + {"custom": g.from_context('.profile["custom key"]')} + ), + thirdProfileData=deno.identity(t.struct({"third": t.string()})).apply( + {"third": g.from_context("profile.data[2]")} + ), + deeplyNestedEntry=deno.identity(t.struct({"value": t.string()})).apply( + {"value": g.from_context('profile.deeply[0]["nested"][1].value')} + ), + optional=deno.identity(t.struct({"optional": t.email().optional()})).apply( + {"optional": g.from_context("profile.email")} + ), + ) diff --git a/typegate/tests/params/apply_test.ts b/typegate/tests/params/apply_test.ts index 0bccbc3a7..74c2cc6e3 100644 --- a/typegate/tests/params/apply_test.ts +++ b/typegate/tests/params/apply_test.ts @@ -130,3 +130,111 @@ Meta.test("(python (sdk): apply)", async (t) => { .on(e); }); }); + +Meta.test("nested context access", async (t) => { + const e = await t.engine("params/apply_nested_context.py"); + + await t.should("work with nested context", async () => { + await gql` + query { + simple { + id + } + } + ` + .withContext({ + profile: { id: 123 }, + }) + .expectData({ + simple: { id: 123 }, + }) + .on(e); + }); + + await t.should("work with custom key", async () => { + await gql` + query { + customKey { + custom + } + } + ` + .withContext({ + profile: { "custom key": "custom value" }, + }) + .expectData({ + customKey: { custom: "custom value" }, + }) + .on(e); + }); + + await t.should("work with array index", async () => { + await gql` + query { + thirdProfileData { + third + } + } + ` + .withContext({ + profile: { data: [true, 456, "hum"] }, + }) + .expectData({ + thirdProfileData: { third: "hum" }, + }) + .on(e); + }); + + await t.should("work with deeply nested value", async () => { + await gql` + query { + deeplyNestedEntry { + value + } + } + ` + .withContext({ + profile: { + deeply: [ + { nested: [{ value: "Hello" }, { value: "world" }] }, + ], + }, + }) + .expectData({ + deeplyNestedEntry: { value: "world" }, + }) + .on(e); + }); + + await t.should("fail for invalid context", async () => { + await gql` + query { + thirdProfileData { + third + } + } + ` + .withContext({ + profile: { datum: 123 }, + }) + .expectErrorContains("Property 'data' not found at `$.profile`") + .on(e); + }); + + await t.should("work with invalid context for optional type", async () => { + await gql` + query { + optional { + optional + } + } + ` + .withContext({ + profile: { datum: 123 }, + }) + .expectData({ + optional: {}, + }) + .on(e); + }); +}); diff --git a/typegraph/core/src/lib.rs b/typegraph/core/src/lib.rs index 9008cec7a..17553834e 100644 --- a/typegraph/core/src/lib.rs +++ b/typegraph/core/src/lib.rs @@ -459,6 +459,7 @@ impl wit::core::Guest for Lib { fn register_context_policy(key: String, check: ContextCheck) -> Result<(PolicyId, String)> { let name = match &check { + ContextCheck::NotNull => format!("__ctx_{}", key), ContextCheck::Value(v) => format!("__ctx_{}_{}", key, v), ContextCheck::Pattern(p) => format!("__ctx_p_{}_{}", key, p), }; @@ -468,6 +469,7 @@ impl wit::core::Guest for Lib { .to_string(); let check = match check { + ContextCheck::NotNull => "value != null".to_string(), ContextCheck::Value(val) => { format!("value === {}", serde_json::to_string(&val).unwrap()) } diff --git a/typegraph/core/wit/typegraph.wit b/typegraph/core/wit/typegraph.wit index ff62c9228..2ed740bf7 100644 --- a/typegraph/core/wit/typegraph.wit +++ b/typegraph/core/wit/typegraph.wit @@ -207,6 +207,7 @@ interface core { get-internal-policy: func() -> result, error> variant context-check { + not-null, value(string), pattern(string), } diff --git a/typegraph/node/sdk/src/policy.ts b/typegraph/node/sdk/src/policy.ts index a768a4e7e..e775fe770 100644 --- a/typegraph/node/sdk/src/policy.ts +++ b/typegraph/node/sdk/src/policy.ts @@ -1,7 +1,7 @@ // Copyright Metatype OÜ, licensed under the Mozilla Public License Version 2.0. // SPDX-License-Identifier: MPL-2.0 -import { MaterializerId } from "./gen/interfaces/metatype-typegraph-core.js"; +import { ContextCheck, MaterializerId } from "./gen/interfaces/metatype-typegraph-core.js"; import { core } from "./wit.js"; interface PolicyPerEffectAlt { @@ -14,23 +14,34 @@ interface PolicyPerEffectAlt { export class PolicyPerEffectObject { constructor( public readonly value: PolicyPerEffectAlt, - ) {} + ) { } } export default class Policy { - constructor(public readonly _id: number, public readonly name: string) {} + constructor(public readonly _id: number, public readonly name: string) { } static public(): Policy { const [id, name] = core.getPublicPolicy(); return new Policy(id, name); } - static context(key: string, check: string | RegExp): Policy { + static #serializeContext(check: string | RegExp | null): ContextCheck { + if (check === null) { + return { tag: "not-null" } + } + if (typeof check === "string") { + return { tag: "value", val: check } + } + if (!(check instanceof RegExp)) { + throw new Error("Invalid context check: expected null, string, or RegExp"); + } + return { tag: "pattern", val: check.source } + } + + static context(key: string, check?: string | RegExp | null): Policy { const [id, name] = core.registerContextPolicy( key, - typeof check === "string" - ? { tag: "value", val: check } - : { tag: "pattern", val: check.source }, + Policy.#serializeContext(check ?? null), ); return new Policy(id, name); } diff --git a/typegraph/python/typegraph/policy.py b/typegraph/python/typegraph/policy.py index 7129593f1..869457183 100644 --- a/typegraph/python/typegraph/policy.py +++ b/typegraph/python/typegraph/policy.py @@ -8,6 +8,7 @@ from typegraph.gen.exports.core import ( ContextCheckPattern, ContextCheckValue, + ContextCheckNotNull, Err, MaterializerId, PolicySpecPerEffect, @@ -44,8 +45,10 @@ def public(cls): return cls(id=res.value[0], name=res.value[1]) @classmethod - def context(cls, key: str, check: Union[str, Pattern]) -> "Policy": - if isinstance(check, str): + def context(cls, key: str, check: Optional[Union[str, Pattern]] = None) -> "Policy": + if check is None: + res = core.register_context_policy(store, key, ContextCheckNotNull()) + elif isinstance(check, str): res = core.register_context_policy(store, key, ContextCheckValue(check)) else: res = core.register_context_policy(