Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,42 @@ type Control =

`Control` is a payload that is wrapped with `TransportMessage`.

### Non-strict input parsing

By default, River validates the input to procedure calls (`requestInit`, `requestData`, and so on) using a mundane type check via TypeBox. If we had a schema like so:

```ts
const schema = Type.Object({
a: Type.String(),
b: Type.Number(),
});
```

Then the following input would fail to validate:

```json
{ "a": "foo" }
```

as the `b` property is not present.

Optionally, separately on the server and transport you can set `strictInputParsing` to `false` which will make River be more liberal on what it considers valid - even up to defaulting values if this would make the input pass validation. The previous example input that failed would actually pass in non-strict mode, and it would be parsed and yielded to procedure handlers as:

```json
{ "a": "foo", "b": 0 }
```

Conceptually this is intended to be closest to [what proto3 has to do to enable wire-safe changes to schemas](https://protobuf.dev/programming-guides/proto3/#wire-safe-changes). It requires that procedure handlers do additional work in validating their input - but it makes it much safer to host River servers that are deployed separately from their consumers. Care must be taken still, as TypeBox is much more expressive and it's much harder to make the same guarantees as proto3 does.

Non-strict parsing _only applies to user-provided values_. Everything else, typically control messages, are parsed strictly and must pass an immediate validation check. For servers, this means that `requestInit`, `requestData`, and any other procedure input objects are parsed non-strictly, and for transports the extended handshake input metadata is parsed non-strictly.

Non-strict parsing should allow the following cases:

- Adding an additional, non-optional field to a schema.
- Extending an enum's value (the server should default to the first enum member for unknown cases)

TODO

## Streams

Streams tie together a series of messages into a single logical 'stream' of communication associated with a single remote procedure invocation.
Expand Down
86 changes: 86 additions & 0 deletions router/parsing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Static, TSchema } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';
import { castTypeboxValueErrors, ValidationErrors } from './errors';

/**
* Result of parsing an input. This will either hold the result of parsing,
* which in non-strict may involve modifications on the input, or the errors
* if the parsing failed.
*/
export type ParseInputResult<T> =
| { ok: true; value: T }
| { ok: false; errors: Static<typeof ValidationErrors> };

/**
* Options for {@link parseInput}.
*/
export interface ParseInputOptions<T extends TSchema> {
/**
* If the schema should be parsed strictly, meaning that the input value
* is expected to match the schema exactly without any processing. This
* is the default behavior (`true`).
*
* In non-strict mode, the behavior is much closer to protobuf semantics -
* e.g. new fields are defaulted, unknown enum values are set to their first
* member, and so on. Input's may still fail to parse, e.g. if they set a
* known field to the wrong type.
*/
strict?: boolean;

/**
* The schema to parse the input against.
*/
schema: T;

/**
* The input value to parse.
*/
input: unknown;
}

/**
* Parse an input against a schema. This is intended for non-control, client
* provided input. This yields a {@link ParseInputResult} which will either
* hold the result of parsing, which in non-strict may involve modifications on
* the input, or the errors if the parsing failed.
*
* @see {@link ParseInputOptions}
*/
export function parseInput<T extends TSchema>({
strict,
schema,
input,
}: ParseInputOptions<T>): ParseInputResult<Static<T>> {
// default path, we just check the value against the schema
if (strict) {
return Value.Check(schema, input)
? { ok: true, value: input }
: {
ok: false,
errors: castTypeboxValueErrors(Value.Errors(schema, input)),
};
}

let parsed = input;

try {
// TODO: switch to Value.Parse when we have it
// parsed = Value.Parse(['Clone', 'Clean', 'Default', 'Decode'], value);
parsed = Value.Clone(parsed);
parsed = Value.Clean(schema, parsed);
parsed = Value.Default(schema, parsed);
// skipped: Value.Convert(schema, parsed);
// unavailable: Value.Assert(schema, parsed);
parsed = Value.Decode(schema, parsed);
} catch {
return {
ok: false,
// we intentionally get the errors for the parsed value we currently have,
// as that signifies the point in parsing in which we failed to continue
// cleaning up the input.
errors: castTypeboxValueErrors(Value.Errors(schema, parsed)),
};
}

return { ok: true, value: parsed };
}
Loading
Loading