From 10c73a4c085db05fc28ada7fb58775f7347ea333 Mon Sep 17 00:00:00 2001 From: Natoandro Date: Wed, 21 Feb 2024 12:08:22 +0300 Subject: [PATCH 1/4] feat: nested context extractor for injections --- typegate/src/engine/planner/args.ts | 21 +- typegate/src/libs/jsonpath.ts | 258 ++++++++++++++++++ typegate/src/services/auth/mod.ts | 7 +- .../src/services/auth/protocols/oauth2.ts | 8 +- typegate/tests/auth/auth.py | 14 + typegate/tests/auth/auth_test.ts | 101 ++++++- 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 +- 10 files changed, 410 insertions(+), 34 deletions(-) create mode 100644 typegate/src/libs/jsonpath.ts diff --git a/typegate/src/engine/planner/args.ts b/typegate/src/engine/planner/args.ts index 0e2706f1b..3b2aa263e 100644 --- a/typegate/src/engine/planner/args.ts +++ b/typegate/src/engine/planner/args.ts @@ -31,6 +31,7 @@ import { generateValidator } from "../typecheck/input.ts"; import { getParentId } from "../stage_id.ts"; import { BadContext } from "../../errors.ts"; import { selectInjection } from "./injection_utils.ts"; +import { QueryFunction as JsonPathQuery } from "../../libs/jsonpath.ts"; class MandatoryArgumentError extends Error { constructor(argDetails: string) { @@ -637,20 +638,20 @@ 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: true }) + .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); + } catch (e) { + const msg = e.message; + throw new BadContext("Error while querying context: " + msg); } - return value; }; } diff --git a/typegate/src/libs/jsonpath.ts b/typegate/src/libs/jsonpath.ts new file mode 100644 index 000000000..517d34c7c --- /dev/null +++ b/typegate/src/libs/jsonpath.ts @@ -0,0 +1,258 @@ +// 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); + return parser.parse(); +} + +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(); + } + + return false; + } + + #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`); + } + 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) + }`, + ); + } + 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; +}; + +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)) { return undefined; }`); + } else { + const error = + `Could not resolve \`${this.#currentSegment}\` at \`${this.#currentPath}\``; + this.#lines.push( + "if (!Array.isArray(value)) {", + ` throw new Error(${JSON.stringify(error)});`, + "}", + ); + } + this.#lines.push(`value = value[${segment.index}];`); + } + + #compileObjectSegment(segment: ObjectKey) { + if (!this.options.strict) { + this.#lines.push( + `if (typeof value !== "object" || value === null) { return undefined; }`, + ); + } else { + const error = + `Could not resolve \`${this.#currentSegment}\` at \`${this.#currentPath}\``; + this.#lines.push( + "if (typeof value !== 'object' || value === null) {", + ` throw new Error(${JSON.stringify(error)});`, + "}", + ); + } + this.#lines.push(`value = value[${JSON.stringify(segment.key)}];`); + } + + get #currentPath() { + return 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.py b/typegate/tests/auth/auth.py index 7532d639e..9f8bbf2d4 100644 --- a/typegate/tests/auth/auth.py +++ b/typegate/tests/auth/auth.py @@ -18,6 +18,7 @@ def test_auth(g: Graph): with_token = deno.policy( "with_token", "(_args, { context }) => !!context.accessToken" ) + has_profile = Policy.context("profile") x = t.struct({"x": t.integer()}) @@ -57,4 +58,17 @@ def test_auth(g: Graph): ), auth_token_field="token", ).with_policy(public), + injectedId=deno.identity( + # TODO validate the path against the profiler result?? + t.struct({"id": t.integer().from_context("profile.id")}) + ).with_policy(has_profile), + secondProfileData=deno.identity( + t.struct({"second": t.integer().from_context("profile.data[1]")}) + ).with_policy(has_profile), + customKey=deno.identity( + t.struct({"custom": t.integer().from_context('profile["custom key"]')}) + ).with_policy(has_profile), + # appliedId=deno.identity(t.struct({"id": t.integer()})) + # .apply({"id": g.from_context("profile.id")}) + # .with_policy(has_profile), ) diff --git a/typegate/tests/auth/auth_test.ts b/typegate/tests/auth/auth_test.ts index 83e7bcf6e..f5957982a 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); @@ -387,3 +387,96 @@ Meta.test("Auth", async (t) => { .on(e); }); }); + +Meta.test("auth: nested profile", async (t) => { + const clientId = "client_id_1"; + const clientSecret = "client_secret_1"; + const e = await t.engine("auth/auth.py", { + secrets: { + GITHUB_CLIENT_ID: clientId, + GITHUB_CLIENT_SECRET: clientSecret, + }, + }); + + 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("access applied profile", async () => { + // await gql` + // query { + // appliedId { + // id + // } + // } + // ` + // .withContext({ id: 123 }) + // .expectData({ + // appliedId: { + // id: 123, + // }, + // }) + // .on(e); + // }); +}); diff --git a/typegraph/core/src/lib.rs b/typegraph/core/src/lib.rs index f6ca4f47f..3f08ab028 100644 --- a/typegraph/core/src/lib.rs +++ b/typegraph/core/src/lib.rs @@ -415,6 +415,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), }; @@ -424,6 +425,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 4cab2d005..54d601048 100644 --- a/typegraph/core/wit/typegraph.wit +++ b/typegraph/core/wit/typegraph.wit @@ -185,6 +185,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( From b24c5fe4bb46bdd9b80bfd15c91a50551771a26b Mon Sep 17 00:00:00 2001 From: Natoandro Date: Wed, 21 Feb 2024 14:38:20 +0300 Subject: [PATCH 2/4] move nested context injection tests --- typegate/src/engine/planner/args.ts | 7 +- typegate/src/libs/jsonpath.ts | 52 +++++++---- typegate/tests/auth/auth.py | 11 --- typegate/tests/auth/auth_test.ts | 93 ------------------- typegate/tests/injection/injection_test.ts | 74 ++++++++++++++- typegate/tests/injection/nested_context.py | 22 +++++ typegate/tests/params/apply_nested_context.py | 0 7 files changed, 133 insertions(+), 126 deletions(-) create mode 100644 typegate/tests/injection/nested_context.py create mode 100644 typegate/tests/params/apply_nested_context.py diff --git a/typegate/src/engine/planner/args.ts b/typegate/src/engine/planner/args.ts index eff1ca604..cd2716c50 100644 --- a/typegate/src/engine/planner/args.ts +++ b/typegate/src/engine/planner/args.ts @@ -663,11 +663,14 @@ class ArgumentCollector { return null; } this.deps.context.add(contextPath); - const queryContext = JsonPathQuery.create(contextPath, { strict: true }) + const queryContext = JsonPathQuery.create(contextPath, { + strict: typ.type !== Type.OPTIONAL, + rootPath: "", + }) .asFunction(); return ({ context }) => { try { - return queryContext(context); + return queryContext(context) ?? null; } catch (e) { const msg = e.message; throw new BadContext("Error while querying context: " + msg); diff --git a/typegate/src/libs/jsonpath.ts b/typegate/src/libs/jsonpath.ts index 517d34c7c..a875f52b3 100644 --- a/typegate/src/libs/jsonpath.ts +++ b/typegate/src/libs/jsonpath.ts @@ -161,6 +161,7 @@ export type QueryFn = (value: unknown) => unknown; export type JsonPathQueryOptions = { strict: boolean; + rootPath?: string; }; export class QueryFunction { @@ -169,6 +170,9 @@ export class QueryFunction { static create(path: string, options: JsonPathQueryOptions) { const compiler = new QueryFnCompiler(path, options); const body = compiler.compile(); + // console.log("----- BODY:", path, options); + // console.log(body); + // console.log("-----"); return new QueryFunction(body); } @@ -215,39 +219,49 @@ class QueryFnCompiler { } #compileArraySegment(segment: ArrayIndex) { - if (!this.options.strict) { - this.#lines.push(`if (!Array.isArray(value)) { return undefined; }`); + 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 { - const error = - `Could not resolve \`${this.#currentSegment}\` at \`${this.#currentPath}\``; - this.#lines.push( - "if (!Array.isArray(value)) {", - ` throw new Error(${JSON.stringify(error)});`, - "}", - ); + 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) { return undefined; }`, - ); + 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 { - const error = - `Could not resolve \`${this.#currentSegment}\` at \`${this.#currentPath}\``; this.#lines.push( - "if (typeof value !== 'object' || value === null) {", - ` throw new Error(${JSON.stringify(error)});`, - "}", + `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() { - return this.#path.slice(0, this.#currentIndex) + const rootPath = this.options.rootPath ?? "$"; + return rootPath + this.#path.slice(0, this.#currentIndex) .map(stringifySegment) .join(""); } diff --git a/typegate/tests/auth/auth.py b/typegate/tests/auth/auth.py index 9f8bbf2d4..156d91203 100644 --- a/typegate/tests/auth/auth.py +++ b/typegate/tests/auth/auth.py @@ -18,7 +18,6 @@ def test_auth(g: Graph): with_token = deno.policy( "with_token", "(_args, { context }) => !!context.accessToken" ) - has_profile = Policy.context("profile") x = t.struct({"x": t.integer()}) @@ -58,16 +57,6 @@ def test_auth(g: Graph): ), auth_token_field="token", ).with_policy(public), - injectedId=deno.identity( - # TODO validate the path against the profiler result?? - t.struct({"id": t.integer().from_context("profile.id")}) - ).with_policy(has_profile), - secondProfileData=deno.identity( - t.struct({"second": t.integer().from_context("profile.data[1]")}) - ).with_policy(has_profile), - customKey=deno.identity( - t.struct({"custom": t.integer().from_context('profile["custom key"]')}) - ).with_policy(has_profile), # appliedId=deno.identity(t.struct({"id": t.integer()})) # .apply({"id": g.from_context("profile.id")}) # .with_policy(has_profile), diff --git a/typegate/tests/auth/auth_test.ts b/typegate/tests/auth/auth_test.ts index f5957982a..4fc3e9202 100644 --- a/typegate/tests/auth/auth_test.ts +++ b/typegate/tests/auth/auth_test.ts @@ -387,96 +387,3 @@ Meta.test("Auth", async (t) => { .on(e); }); }); - -Meta.test("auth: nested profile", async (t) => { - const clientId = "client_id_1"; - const clientSecret = "client_secret_1"; - const e = await t.engine("auth/auth.py", { - secrets: { - GITHUB_CLIENT_ID: clientId, - GITHUB_CLIENT_SECRET: clientSecret, - }, - }); - - 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("access applied profile", async () => { - // await gql` - // query { - // appliedId { - // id - // } - // } - // ` - // .withContext({ id: 123 }) - // .expectData({ - // appliedId: { - // id: 123, - // }, - // }) - // .on(e); - // }); -}); diff --git a/typegate/tests/injection/injection_test.ts b/typegate/tests/injection/injection_test.ts index 48bcd56fa..9ee576145 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,75 @@ 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); + }, + ); + + // TODO invalid injection + // TODO optional injection with nested context +}); diff --git a/typegate/tests/injection/nested_context.py b/typegate/tests/injection/nested_context.py new file mode 100644 index 000000000..3a74efceb --- /dev/null +++ b/typegate/tests/injection/nested_context.py @@ -0,0 +1,22 @@ +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"]')}) + ), + ) diff --git a/typegate/tests/params/apply_nested_context.py b/typegate/tests/params/apply_nested_context.py new file mode 100644 index 000000000..e69de29bb From c510894b6c3dfc880c1cd5694f94cf24f4155d2b Mon Sep 17 00:00:00 2001 From: Natoandro Date: Wed, 21 Feb 2024 22:38:19 +0300 Subject: [PATCH 3/4] nested context on apply --- .../engine/planner/parameter_transformer.ts | 95 +++++++++++++++++-- .../typecheck/inline_validators/number.ts | 4 +- typegate/src/libs/jsonpath.ts | 12 +-- typegate/tests/auth/auth.py | 3 - typegate/tests/params/apply_nested_context.py | 24 +++++ typegate/tests/params/apply_test.ts | 76 +++++++++++++++ 6 files changed, 194 insertions(+), 20 deletions(-) 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/libs/jsonpath.ts b/typegate/src/libs/jsonpath.ts index a875f52b3..b75dc1a22 100644 --- a/typegate/src/libs/jsonpath.ts +++ b/typegate/src/libs/jsonpath.ts @@ -26,7 +26,8 @@ export type PathSegment = ArrayIndex | ObjectKey; function parsePath(path: string): PathSegment[] { const parser = new PathParser(path); - return parser.parse(); + const res = parser.parse(); + return res; } class PathParser { @@ -68,7 +69,7 @@ class PathParser { return this.#parseIndex(); } - return false; + throw new Error(`Unexpected character: ${firstChar}`); } #parseKey(): boolean { @@ -106,6 +107,7 @@ class PathParser { 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 } } @@ -129,6 +131,7 @@ class PathParser { }`, ); } + this.#currentIndex = end; return this.#append({ type: "array", index }, 1); } } @@ -170,9 +173,6 @@ export class QueryFunction { static create(path: string, options: JsonPathQueryOptions) { const compiler = new QueryFnCompiler(path, options); const body = compiler.compile(); - // console.log("----- BODY:", path, options); - // console.log(body); - // console.log("-----"); return new QueryFunction(body); } @@ -181,7 +181,7 @@ export class QueryFunction { } asFunctionDef(name: string) { - if (!/^[A-Za-z_]\w*$/.test(name)) { + if (/^[A-Za-z_]\w*$/.test(name)) { return `function ${name}(initialValue) {\n${this.code}\n}`; } else { throw new Error(`Invalid function name: ${name}`); diff --git a/typegate/tests/auth/auth.py b/typegate/tests/auth/auth.py index 156d91203..7532d639e 100644 --- a/typegate/tests/auth/auth.py +++ b/typegate/tests/auth/auth.py @@ -57,7 +57,4 @@ def test_auth(g: Graph): ), auth_token_field="token", ).with_policy(public), - # appliedId=deno.identity(t.struct({"id": t.integer()})) - # .apply({"id": g.from_context("profile.id")}) - # .with_policy(has_profile), ) diff --git a/typegate/tests/params/apply_nested_context.py b/typegate/tests/params/apply_nested_context.py index e69de29bb..db7acfbea 100644 --- a/typegate/tests/params/apply_nested_context.py +++ b/typegate/tests/params/apply_nested_context.py @@ -0,0 +1,24 @@ +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')} + ), + ) diff --git a/typegate/tests/params/apply_test.ts b/typegate/tests/params/apply_test.ts index 0bccbc3a7..494717eea 100644 --- a/typegate/tests/params/apply_test.ts +++ b/typegate/tests/params/apply_test.ts @@ -130,3 +130,79 @@ 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); + }); +}); From a423dfc9a1d8713537901b96c2bf6d7e1d99fc14 Mon Sep 17 00:00:00 2001 From: Natoandro Date: Fri, 23 Feb 2024 02:09:57 +0300 Subject: [PATCH 4/4] test: add more tests --- typegate/deno.lock | 8 ---- .../typecheck/inline_validators/string.ts | 2 +- typegate/tests/injection/injection_test.ts | 45 ++++++++++++++++++- typegate/tests/injection/nested_context.py | 3 ++ typegate/tests/params/apply_nested_context.py | 3 ++ typegate/tests/params/apply_test.ts | 32 +++++++++++++ 6 files changed, 82 insertions(+), 11 deletions(-) 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/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/tests/injection/injection_test.ts b/typegate/tests/injection/injection_test.ts index 9ee576145..b2696a883 100644 --- a/typegate/tests/injection/injection_test.ts +++ b/typegate/tests/injection/injection_test.ts @@ -368,6 +368,47 @@ Meta.test("Injection from nested context", async (t) => { }, ); - // TODO invalid injection - // TODO optional injection with nested context + 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 index 3a74efceb..207323e90 100644 --- a/typegate/tests/injection/nested_context.py +++ b/typegate/tests/injection/nested_context.py @@ -19,4 +19,7 @@ def test_nested_context(g: Graph): 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 index db7acfbea..1335bdbac 100644 --- a/typegate/tests/params/apply_nested_context.py +++ b/typegate/tests/params/apply_nested_context.py @@ -21,4 +21,7 @@ def apply_nested_context(g: Graph): 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 494717eea..74c2cc6e3 100644 --- a/typegate/tests/params/apply_test.ts +++ b/typegate/tests/params/apply_test.ts @@ -205,4 +205,36 @@ Meta.test("nested context access", async (t) => { }) .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); + }); });