diff --git a/__tests__/__snapshots__/serialize.test.ts.snap b/__tests__/__snapshots__/serialize.test.ts.snap index 2d8bdbd9..72114ad8 100644 --- a/__tests__/__snapshots__/serialize.test.ts.snap +++ b/__tests__/__snapshots__/serialize.test.ts.snap @@ -57,6 +57,36 @@ exports[`serialize server to jsonschema > serialize entire service schema 1`] = "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -150,6 +180,36 @@ exports[`serialize server to jsonschema > serialize entire service schema 1`] = "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -238,6 +298,36 @@ exports[`serialize server to jsonschema > serialize entire service schema 1`] = "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -330,6 +420,36 @@ exports[`serialize server to jsonschema > serialize entire service schema 1`] = "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -432,6 +552,36 @@ exports[`serialize server to jsonschema > serialize entire service schema 1`] = "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -563,6 +713,36 @@ exports[`serialize server to jsonschema > serialize entire service schema 1`] = "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -671,6 +851,36 @@ exports[`serialize server to jsonschema > serialize entire service schema 1`] = "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -750,6 +960,36 @@ exports[`serialize server to jsonschema > serialize entire service schema 1`] = "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -842,6 +1082,36 @@ exports[`serialize service to jsonschema > serialize backwards compatible with v "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -935,6 +1205,36 @@ exports[`serialize service to jsonschema > serialize backwards compatible with v "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -1023,6 +1323,36 @@ exports[`serialize service to jsonschema > serialize backwards compatible with v "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -1115,6 +1445,36 @@ exports[`serialize service to jsonschema > serialize backwards compatible with v "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -1217,6 +1577,36 @@ exports[`serialize service to jsonschema > serialize backwards compatible with v "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -1348,6 +1738,36 @@ exports[`serialize service to jsonschema > serialize backwards compatible with v "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -1456,6 +1876,36 @@ exports[`serialize service to jsonschema > serialize backwards compatible with v "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -1535,6 +1985,36 @@ exports[`serialize service to jsonschema > serialize backwards compatible with v "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -1625,6 +2105,36 @@ exports[`serialize service to jsonschema > serialize basic service 1`] = ` "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -1718,6 +2228,36 @@ exports[`serialize service to jsonschema > serialize basic service 1`] = ` "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -1806,6 +2346,36 @@ exports[`serialize service to jsonschema > serialize basic service 1`] = ` "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -1898,6 +2468,36 @@ exports[`serialize service to jsonschema > serialize basic service 1`] = ` "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -2000,6 +2600,36 @@ exports[`serialize service to jsonschema > serialize basic service 1`] = ` "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -2131,6 +2761,36 @@ exports[`serialize service to jsonschema > serialize basic service 1`] = ` "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -2239,6 +2899,36 @@ exports[`serialize service to jsonschema > serialize basic service 1`] = ` "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -2318,6 +3008,36 @@ exports[`serialize service to jsonschema > serialize basic service 1`] = ` "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -2408,6 +3128,36 @@ exports[`serialize service to jsonschema > serialize service with binary 1`] = ` "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -2552,6 +3302,36 @@ exports[`serialize service to jsonschema > serialize service with errors 1`] = ` "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, @@ -2665,6 +3445,36 @@ exports[`serialize service to jsonschema > serialize service with errors 1`] = ` "const": "INVALID_REQUEST", "type": "string", }, + "extras": { + "properties": { + "firstValidationErrors": { + "items": { + "properties": { + "message": { + "type": "string", + }, + "path": { + "type": "string", + }, + }, + "required": [ + "path", + "message", + ], + "type": "object", + }, + "type": "array", + }, + "totalErrors": { + "type": "number", + }, + }, + "required": [ + "firstValidationErrors", + "totalErrors", + ], + "type": "object", + }, "message": { "type": "string", }, diff --git a/__tests__/invalid-request.test.ts b/__tests__/invalid-request.test.ts index 37681fdd..96dc3f87 100644 --- a/__tests__/invalid-request.test.ts +++ b/__tests__/invalid-request.test.ts @@ -391,10 +391,15 @@ describe('cancels invalid request', () => { streamId, payload: Err({ code: INVALID_REQUEST_CODE, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - message: expect.stringContaining( - 'expected requestData or control payload', - ), + message: 'message in requestData position did not match schema', + extras: { + totalErrors: 2, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + firstValidationErrors: expect.arrayContaining([ + { path: '/mustSendThings', message: 'Required property' }, + { path: '/mustSendThings', message: 'Expected string' }, + ]), + }, }), }), ); @@ -451,8 +456,14 @@ describe('cancels invalid request', () => { streamId, payload: Err({ code: INVALID_REQUEST_CODE, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - message: expect.stringContaining('control payload'), + message: 'message in control payload position did not match schema', + extras: { + totalErrors: 1, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + firstValidationErrors: expect.arrayContaining([ + { path: '', message: 'Expected union value' }, + ]), + }, }), }), ); @@ -578,8 +589,16 @@ describe('cancels invalid request', () => { code: INVALID_REQUEST_CODE, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment message: expect.stringContaining( - 'expected requestData or control payload', + 'message in requestData position did not match schema', ), + extras: { + totalErrors: 2, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + firstValidationErrors: expect.arrayContaining([ + { path: '/newRequiredField', message: 'Required property' }, + { path: '/newRequiredField', message: 'Expected string' }, + ]), + }, }), ]); }); diff --git a/logging/log.ts b/logging/log.ts index de77449a..7ca33bfa 100644 --- a/logging/log.ts +++ b/logging/log.ts @@ -1,4 +1,3 @@ -import { ValueError } from '@sinclair/typebox/value'; import { OpaqueTransportMessage, ProtocolVersion } from '../transport/message'; import { context, trace } from '@opentelemetry/api'; @@ -61,7 +60,7 @@ export type MessageMetadata = Partial<{ sessionId: string; connId: string; transportMessage: Partial; - validationErrors: Array; + validationErrors: Array<{ path: string; message: string }>; tags: Array; telemetry: { traceId: string; diff --git a/router/errors.ts b/router/errors.ts index 623ed771..b8dac347 100644 --- a/router/errors.ts +++ b/router/errors.ts @@ -1,5 +1,6 @@ import { Kind, + Static, TLiteral, TNever, TObject, @@ -8,6 +9,7 @@ import { TUnion, Type, } from '@sinclair/typebox'; +import { ValueErrorIterator } from '@sinclair/typebox/errors'; /** * {@link UNCAUGHT_ERROR_CODE} is the code that is used when an error is thrown @@ -50,6 +52,26 @@ export const ErrResultSchema = (t: T) => payload: t, }); +const ValidationErrorDetails = Type.Object({ + path: Type.String(), + message: Type.String(), +}); + +export const ValidationErrors = Type.Array(ValidationErrorDetails); +export function castTypeboxValueErrors( + errors: ValueErrorIterator, +): Static { + const result = []; + for (const error of errors) { + result.push({ + path: error.path, + message: error.message, + }); + } + + return result; +} + /** * {@link ReaderErrorSchema} is the schema for all the built-in river errors that * can be emitted to a reader (request reader on the server, and response reader @@ -67,6 +89,12 @@ export const ReaderErrorSchema = Type.Union([ Type.Object({ code: Type.Literal(INVALID_REQUEST_CODE), message: Type.String(), + extras: Type.Optional( + Type.Object({ + firstValidationErrors: Type.Array(ValidationErrorDetails), + totalErrors: Type.Number(), + }), + ), }), Type.Object({ code: Type.Literal(CANCEL_CODE), diff --git a/router/server.ts b/router/server.ts index 0a6ff61b..37efa7a1 100644 --- a/router/server.ts +++ b/router/server.ts @@ -8,6 +8,8 @@ import { INVALID_REQUEST_CODE, BaseErrorSchemaType, ErrResultSchema, + ValidationErrors, + castTypeboxValueErrors, } from './errors'; import { AnyService, @@ -32,7 +34,7 @@ import { ParsedMetadata, } from './context'; import { Logger } from '../logging/log'; -import { Value, ValueError } from '@sinclair/typebox/value'; +import { Value } from '@sinclair/typebox/value'; import { Err, Result, Ok, ErrResult } from './result'; import { EventMap } from '../transport/events'; import { coerceErrorString } from '../transport/stringifyError'; @@ -363,30 +365,37 @@ class RiverServer } // We couldn't make sense of the message, it's probably a bad request - let validationErrors: Array; + let validationErrors: Static; let errMessage: string; if ('requestData' in procedure) { - errMessage = 'expected requestData or control payload'; - validationErrors = [ - ...Value.Errors(procedure.responseData, msg.payload), - ]; + errMessage = 'message in requestData position did not match schema'; + validationErrors = castTypeboxValueErrors( + Value.Errors(procedure.requestData, msg.payload), + ); } else { - validationErrors = [ - ...Value.Errors(ControlMessagePayloadSchema, msg.payload), - ]; - errMessage = 'expected control payload'; + validationErrors = castTypeboxValueErrors( + Value.Errors(ControlMessagePayloadSchema, msg.payload), + ); + errMessage = 'message in control payload position did not match schema'; } this.log?.warn(errMessage, { ...loggingMetadata, transportMessage: msg, - validationErrors, + validationErrors: validationErrors.map((error) => ({ + path: error.path, + message: error.message, + })), tags: ['invalid-request'], }); onServerCancel({ code: INVALID_REQUEST_CODE, message: errMessage, + extras: { + totalErrors: validationErrors.length, + firstValidationErrors: validationErrors.slice(0, 5), + }, }); };