Skip to content

Commit

Permalink
Add validation with valita
Browse files Browse the repository at this point in the history
  • Loading branch information
timonson committed May 18, 2024
1 parent bc703c1 commit 8e58507
Show file tree
Hide file tree
Showing 19 changed files with 170 additions and 46 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -52,7 +52,7 @@ function animalsMakeNoise(noise: string[]) {
}

const methods = {
add: add,
add: { method: add, validation: numberArrayValidator },
makeName: makeName,
animalsMakeNoise: animalsMakeNoise,
};
Expand Down
2 changes: 1 addition & 1 deletion client/client.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
2 changes: 1 addition & 1 deletion client/creation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RpcRequest } from "../rpc_types.ts";
import { RpcRequest } from "../types.ts";
import { generateUlid } from "./deps.ts";

export type CreateRequestInput = {
Expand Down
2 changes: 1 addition & 1 deletion client/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 7 additions & 2 deletions examples/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@ 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"],
}, {
method: "animalsMakeNoise",
params: ["aaa", "bbb"],
}, { method: "animalsMakeNoise", params: ["aaa", "bbb"] }]);

console.log(r3);
console.log(r4);
4 changes: 2 additions & 2 deletions examples/server.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,7 +16,7 @@ function animalsMakeNoise(noise: string[]) {
}

const methods = {
add: add,
add: { method: add, validation: numberArrayValidator },
makeName: makeName,
animalsMakeNoise: animalsMakeNoise,
};
Expand Down
15 changes: 5 additions & 10 deletions server/auth.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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 };
Expand Down
43 changes: 33 additions & 10 deletions server/creation.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,18 +25,36 @@ type RpcBatchResponseOrNull = RpcBatchResponse | null;

async function executeMethods(
{ validationObject, methods, options, payload }: CreationInput & {
payload?: Payload;
payload?: JsonObject;
},
): Promise<ValidationObject> {
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) {
Expand All @@ -54,7 +77,7 @@ async function executeMethods(

async function createRpcResponse(
{ validationObject, methods, options, payload }: CreationInput & {
payload?: Payload;
payload?: JsonObject;
},
): Promise<RpcResponseOrNull> {
const obj: ValidationObject = await executeMethods(
Expand Down
2 changes: 1 addition & 1 deletion server/custom_error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type JsonValue } from "../rpc_types.ts";
import { type JsonValue } from "../types.ts";

export class CustomError extends Error {
code: number;
Expand Down
7 changes: 2 additions & 5 deletions server/deps.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
4 changes: 4 additions & 0 deletions server/error_data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};
12 changes: 12 additions & 0 deletions server/method.ts
Original file line number Diff line number Diff line change
@@ -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<JsonValue>;
export type Methods = {
[method: string]: Method | {
method: Method;
// deno-lint-ignore no-explicit-any
validation: Type<any>;
};
};
2 changes: 2 additions & 0 deletions server/mod.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from "./response.ts";
export * from "./method.ts";
export * from "./util.ts";
6 changes: 1 addition & 5 deletions server/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<JsonValue>;
};
export type Options = {
// Add response headers:
headers?: Headers;
Expand Down
37 changes: 35 additions & 2 deletions server/response_test.ts
Original file line number Diff line number Diff line change
@@ -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" });
Expand All @@ -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) =>
Expand All @@ -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(
Expand Down Expand Up @@ -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
> {
Expand Down
53 changes: 53 additions & 0 deletions server/util.ts
Original file line number Diff line number Diff line change
@@ -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<JsonValue> = v.lazy(() =>
v.union(primitiveValidator, objectValidator, arrayValidator)
);
export const objectValidator: v.Type<JsonObject> = v.lazy(() =>
v.record(valueValidator)
);
export const arrayValidator: v.Type<JsonArray> = v.lazy(() =>
v.array(valueValidator)
);
export const stringOrNullValidator: v.Type<StringOrNull> = v.union(
stringValidator,
nullValidator,
);
export const numberOrNullValidator: v.Type<NumberOrNull> = 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);
1 change: 1 addition & 0 deletions server/util_deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as v from "https://deno.land/x/valita@v0.3.8/mod.ts";
11 changes: 7 additions & 4 deletions server/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
File renamed without changes.

0 comments on commit 8e58507

Please sign in to comment.