diff --git a/README.md b/README.md index 90704a3..dd368fd 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ console.log(r3); ## Server ```ts -import { respond } from "../server/mod.ts"; +import { numberArrayValidator, respond } from "../server/mod.ts"; function add([a, b]: [number, number]) { return a + b; @@ -52,7 +52,7 @@ function animalsMakeNoise(noise: string[]) { } const methods = { - add: add, + add: { method: add, validation: numberArrayValidator }, makeName: makeName, animalsMakeNoise: animalsMakeNoise, }; diff --git a/client/client.ts b/client/client.ts index fae6a72..02ddb66 100644 --- a/client/client.ts +++ b/client/client.ts @@ -1,4 +1,4 @@ -import { JsonValue, RpcBatchRequest, RpcRequest } from "../rpc_types.ts"; +import { JsonValue, RpcBatchRequest, RpcRequest } from "../types.ts"; import { createRequest as createRpcRequest, type CreateRequestInput, diff --git a/client/creation.ts b/client/creation.ts index 28d5204..86995ec 100644 --- a/client/creation.ts +++ b/client/creation.ts @@ -1,4 +1,4 @@ -import { RpcRequest } from "../rpc_types.ts"; +import { RpcRequest } from "../types.ts"; import { generateUlid } from "./deps.ts"; export type CreateRequestInput = { diff --git a/client/validation.ts b/client/validation.ts index 715f66a..b51a86a 100644 --- a/client/validation.ts +++ b/client/validation.ts @@ -3,7 +3,7 @@ import type { RpcResponse, RpcResponseBasis, RpcSuccess, -} from "../rpc_types.ts"; +} from "../types.ts"; // deno-lint-ignore no-explicit-any function validateRpcBasis(data: any): data is RpcResponseBasis { diff --git a/examples/client.ts b/examples/client.ts index 0d05faf..ce7d97f 100644 --- a/examples/client.ts +++ b/examples/client.ts @@ -7,13 +7,18 @@ const r2 = await call({ method: "makeName", params: { firstName: "Joe", lastName: "Doe" }, }); +const r3 = await call({ + method: "add", + params: [1, 10], +}); console.log(r1); console.log(r2); +console.log(r3); const batchCall = makeBatchRpcCall("http://localhost:8000"); -const r3 = await batchCall([{ +const r4 = await batchCall([{ method: "animalsMakeNoise", params: ["aaa", "bbb"], }, { @@ -21,4 +26,4 @@ const r3 = await batchCall([{ params: ["aaa", "bbb"], }, { method: "animalsMakeNoise", params: ["aaa", "bbb"] }]); -console.log(r3); +console.log(r4); diff --git a/examples/server.ts b/examples/server.ts index d107fa3..532a231 100644 --- a/examples/server.ts +++ b/examples/server.ts @@ -1,4 +1,4 @@ -import { respond } from "../server/mod.ts"; +import { numberArrayValidator, respond } from "../server/mod.ts"; function add([a, b]: [number, number]) { return a + b; @@ -16,7 +16,7 @@ function animalsMakeNoise(noise: string[]) { } const methods = { - add: add, + add: { method: add, validation: numberArrayValidator }, makeName: makeName, animalsMakeNoise: animalsMakeNoise, }; diff --git a/server/auth.ts b/server/auth.ts index 11665e4..22faa0d 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -1,18 +1,13 @@ import { authErrorData } from "./error_data.ts"; -import { - getJwtFromBearer, - isArray, - isFunction, - isPresent, - type Payload, -} from "./deps.ts"; +import { getJwtFromBearer, isArray, isFunction, isPresent } from "./deps.ts"; +import { type JsonObject } from "../types.ts"; import { type CreationInput } from "./creation.ts"; import { type AuthInput } from "./response.ts"; import { type ValidationSuccess } from "./validation.ts"; export type AuthData = AuthInput & { headers: Headers }; type VerifyJwtForSelectedMethodsReturnType = CreationInput & { - payload?: Payload; + payload?: JsonObject; }; export async function verifyJwtForSelectedMethods( @@ -31,9 +26,9 @@ export async function verifyJwtForSelectedMethods( item.validationObject && item.validationObject.isError ); if (errorOrUndefined) { - return errorOrUndefined as CreationInput & { payload?: Payload }; + return errorOrUndefined as CreationInput & { payload?: JsonObject }; } else if (authResults.length > 0) { - return authResults[0] as CreationInput & { payload?: Payload }; + return authResults[0] as CreationInput & { payload?: JsonObject }; } } return { validationObject, methods, options }; diff --git a/server/creation.ts b/server/creation.ts index f3047d7..ea043b9 100644 --- a/server/creation.ts +++ b/server/creation.ts @@ -1,10 +1,15 @@ -import { internalErrorData } from "./error_data.ts"; +import { internalErrorData, validationErrorData } from "./error_data.ts"; import { CustomError } from "./custom_error.ts"; import { type AuthData, verifyJwtForSelectedMethods } from "./auth.ts"; -import { type RpcBatchResponse, type RpcResponse } from "../rpc_types.ts"; +import { + type JsonObject, + type RpcBatchResponse, + type RpcResponse, +} from "../types.ts"; import { type ValidationObject } from "./validation.ts"; -import { type Methods, type Options } from "./response.ts"; -import { isArray, type Payload } from "./deps.ts"; +import { type Options } from "./response.ts"; +import { type Methods } from "./method.ts"; +import { isArray } from "./deps.ts"; export type CreationInput = { validationObject: ValidationObject; @@ -20,18 +25,36 @@ type RpcBatchResponseOrNull = RpcBatchResponse | null; async function executeMethods( { validationObject, methods, options, payload }: CreationInput & { - payload?: Payload; + payload?: JsonObject; }, ): Promise { if (validationObject.isError) return validationObject; try { - const additionalArgument = { ...options.args, payload }; - const method = methods[validationObject.method]; + const params = validationObject.params; + const additionalArgument = payload + ? { ...options.args, payload } + : { ...options.args }; + const methodOrObject = methods[validationObject.method]; + const { method, validation } = typeof methodOrObject === "function" + ? { method: methodOrObject, validation: null } + : methodOrObject; + if (validation) { + try { + validation.parse(params); + } catch (error) { + return { + id: validationObject.id, + data: error.data, + isError: true, + ...validationErrorData, + }; + } + } return { ...validationObject, result: Object.keys(additionalArgument).length === 0 - ? await method(validationObject.params) - : await method(validationObject.params, additionalArgument), + ? await method(params) + : await method(params, additionalArgument), }; } catch (error) { if (error instanceof CustomError) { @@ -54,7 +77,7 @@ async function executeMethods( async function createRpcResponse( { validationObject, methods, options, payload }: CreationInput & { - payload?: Payload; + payload?: JsonObject; }, ): Promise { const obj: ValidationObject = await executeMethods( diff --git a/server/custom_error.ts b/server/custom_error.ts index 79e8d62..d4a9d05 100644 --- a/server/custom_error.ts +++ b/server/custom_error.ts @@ -1,4 +1,4 @@ -import { type JsonValue } from "../rpc_types.ts"; +import { type JsonValue } from "../types.ts"; export class CustomError extends Error { code: number; diff --git a/server/deps.ts b/server/deps.ts index a70c76f..7013d61 100644 --- a/server/deps.ts +++ b/server/deps.ts @@ -1,8 +1,4 @@ -export { - type Payload, - type VerifyOptions, -} from "https://dev.zaubrik.com/djwt@v3.0.2/mod.ts"; - +export { type VerifyOptions } from "https://dev.zaubrik.com/djwt@v3.0.2/mod.ts"; export { type CryptoKeyOrUpdateInput, getJwtFromBearer, @@ -16,3 +12,4 @@ export { isPresent, isString, } from "https://dev.zaubrik.com/sorcery@v0.1.5/type.js"; +export { type Type } from "https://deno.land/x/valita@v0.3.8/mod.ts"; diff --git a/server/error_data.ts b/server/error_data.ts index 73952c8..b451c79 100644 --- a/server/error_data.ts +++ b/server/error_data.ts @@ -17,3 +17,7 @@ export const invalidRequestErrorData = { * -32000 to -32099 Reserved for implementation-defined server-errors. */ export const authErrorData = { code: -32020, message: "Authorization error" }; +export const validationErrorData = { + code: -32030, + message: "Validation error", +}; diff --git a/server/method.ts b/server/method.ts new file mode 100644 index 0000000..59b0bdd --- /dev/null +++ b/server/method.ts @@ -0,0 +1,12 @@ +import { type JsonValue } from "../types.ts"; +import { type Type } from "./deps.ts"; + +// deno-lint-ignore no-explicit-any +export type Method = (...args: any[]) => JsonValue | Promise; +export type Methods = { + [method: string]: Method | { + method: Method; + // deno-lint-ignore no-explicit-any + validation: Type; + }; +}; diff --git a/server/mod.ts b/server/mod.ts index 488505a..ab9a8c4 100644 --- a/server/mod.ts +++ b/server/mod.ts @@ -1 +1,3 @@ export * from "./response.ts"; +export * from "./method.ts"; +export * from "./util.ts"; diff --git a/server/response.ts b/server/response.ts index f7bfa3b..e23bfc8 100644 --- a/server/response.ts +++ b/server/response.ts @@ -6,12 +6,8 @@ import { } from "./deps.ts"; import { createRpcResponseOrBatch } from "./creation.ts"; import { validateRequest } from "./validation.ts"; -import { type JsonValue } from "../rpc_types.ts"; +import { type Methods } from "./method.ts"; -export type Methods = { - // deno-lint-ignore no-explicit-any - [method: string]: (...arg: any[]) => JsonValue | Promise; -}; export type Options = { // Add response headers: headers?: Headers; diff --git a/server/response_test.ts b/server/response_test.ts index 910df72..e0a82c1 100644 --- a/server/response_test.ts +++ b/server/response_test.ts @@ -1,6 +1,8 @@ -import { assertEquals, create, Payload } from "../test_deps.ts"; +import { assertEquals, create } from "../test_deps.ts"; +import { type JsonObject } from "../types.ts"; import { respond } from "./response.ts"; import { CustomError } from "./custom_error.ts"; +import { numberArrayValidator } from "../server/util.ts"; function createReq(str: string) { return new Request("http://0.0.0.0:8000", { body: str, method: "POST" }); @@ -10,6 +12,10 @@ function removeWhiteSpace(str: string) { return JSON.stringify(JSON.parse(str)); } +function add([a, b]: [number, number]) { + return a + b; +} + const methods = { // deno-lint-ignore no-explicit-any subtract: (input: any) => @@ -28,9 +34,10 @@ const methods = { "details": "error details", }); }, - login: (_: unknown, { payload }: { payload: Payload }) => { + login: (_: unknown, { payload }: { payload: JsonObject }) => { return payload.user as string; }, + add: { method: add, validation: numberArrayValidator }, }; const cryptoKey = await crypto.subtle.generateKey( @@ -238,6 +245,32 @@ Deno.test("rpc call with publicErrorStack set to true", async function (): Promi ); }); +Deno.test("rpc call with validation", async function (): Promise< + void +> { + const sentToServer = + '{"jsonrpc": "2.0", "method": "add", "params": [10, 20], "id": 1}'; + const sentToClient = '{"jsonrpc":"2.0","result":30,"id":1}'; + + assertEquals( + await (await respond(methods)(createReq(sentToServer))).text(), + removeWhiteSpace(sentToClient), + ); +}); +Deno.test("rpc call with failed validation", async function (): Promise< + void +> { + const sentToServer = + '{"jsonrpc": "2.0", "method": "add", "params": [10, "invalid"], "id": 1}'; + const sentToClient = + '{"jsonrpc":"2.0","error":{"code":-32030,"message":"Validation error"},"id":1}'; + + assertEquals( + await (await respond(methods)(createReq(sentToServer))).text(), + removeWhiteSpace(sentToClient), + ); +}); + Deno.test("rpc call with a custom error", async function (): Promise< void > { diff --git a/server/util.ts b/server/util.ts new file mode 100644 index 0000000..45203ea --- /dev/null +++ b/server/util.ts @@ -0,0 +1,53 @@ +import { v } from "./util_deps.ts"; +import { type JsonArray, type JsonObject, type JsonValue } from "../types.ts"; + +export type StringOrNull = string | null; +export type NumberOrNull = number | null; + +// Validators for JsonPrimitive +export const stringValidator = v.string(); +export const numberValidator = v.number(); +export const booleanValidator = v.boolean(); +export const nullValidator = v.null(); + +// Validator for JsonPrimitive (union of all primitive types) +export const primitiveValidator = v.union( + stringValidator, + numberValidator, + booleanValidator, + nullValidator, +); + +// Recursive validators for JsonValue, JsonObject, and JsonArray +export const valueValidator: v.Type = v.lazy(() => + v.union(primitiveValidator, objectValidator, arrayValidator) +); +export const objectValidator: v.Type = v.lazy(() => + v.record(valueValidator) +); +export const arrayValidator: v.Type = v.lazy(() => + v.array(valueValidator) +); +export const stringOrNullValidator: v.Type = v.union( + stringValidator, + nullValidator, +); +export const numberOrNullValidator: v.Type = v.union( + numberValidator, + nullValidator, +); + +// Combination Validators for Arrays and Objects with specific types +export const stringArrayValidator = v.array(stringValidator); +export const numberArrayValidator = v.array(numberValidator); +export const booleanArrayValidator = v.array(booleanValidator); +export const objectArrayValidator = v.array(objectValidator); +export const stringOrNullArrayValidator = v.array(stringOrNullValidator); +export const numberOrNullArrayValidator = v.array(numberOrNullValidator); + +export const stringObjectValidator = v.record(stringValidator); +export const numberObjectValidator = v.record(numberValidator); +export const booleanObjectValidator = v.record(booleanValidator); +export const objectObjectValidator = v.record(objectValidator); +export const stringOrNullObjectValidator = v.record(stringOrNullValidator); +export const numberOrNullObjectValidator = v.record(numberOrNullValidator); diff --git a/server/util_deps.ts b/server/util_deps.ts new file mode 100644 index 0000000..875cd89 --- /dev/null +++ b/server/util_deps.ts @@ -0,0 +1 @@ +export * as v from "https://deno.land/x/valita@v0.3.8/mod.ts"; diff --git a/server/validation.ts b/server/validation.ts index 54799fc..6dcc986 100644 --- a/server/validation.ts +++ b/server/validation.ts @@ -5,15 +5,14 @@ import { methodNotFoundErrorData, parseErrorData, } from "./error_data.ts"; -import { type Methods } from "./response.ts"; - +import { type Methods } from "./method.ts"; import { type JsonArray, type JsonObject, type JsonValue, type RpcId, type RpcMethod, -} from "../rpc_types.ts"; +} from "../types.ts"; export type ValidationSuccess = { isError: false; @@ -98,7 +97,11 @@ export function validateRpcRequestObject( id: isRpcId(decodedBody.id) ? decodedBody.id : null, isError: true, }; - } else if (!(isFunction(methods[decodedBody.method]))) { + } else if ( + !(isFunction(methods[decodedBody.method]) || + // deno-lint-ignore no-explicit-any + isFunction((methods[decodedBody.method] as any)?.method)) + ) { return { id: decodedBody.id, isError: true, diff --git a/rpc_types.ts b/types.ts similarity index 100% rename from rpc_types.ts rename to types.ts