Skip to content
Merged
76 changes: 75 additions & 1 deletion packages/experimental/src/attrs.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import { describe, expect, it } from "vitest";
import { ValidationError } from "./error";
import { v } from "./index";
import { attrsOf, validate, withAttrs } from "./schema";
import {
asType,
attrsOf,
omitFields,
parseFields,
rejectFields,
validate,
withAttrs,
} from "./schema";

describe("schema $attrs", () => {
it("withAttrs merges within a namespace and isolates namespaces", () => {
Expand Down Expand Up @@ -61,3 +69,69 @@ describe("schema $attrs", () => {
).toBeDefined();
});
});

describe("omitFields / rejectFields / parseFields", () => {
const dropMarked = (schema: unknown) =>
attrsOf(schema, "http")?.readonly === true;
const shape = v.object({
id: v.string(),
role: withAttrs(v.string(), "http", { readonly: true }),
meta: v.object({
note: v.string(),
secret: withAttrs(v.string(), "http", { readonly: true }),
}),
});

it("omitFields projects nested objects", () => {
const projected = asType(omitFields(shape, dropMarked));
expect(Object.keys(projected.shape as object).sort()).toEqual([
"id",
"meta",
]);
const meta = asType((projected.shape as Record<string, unknown>).meta);
expect(Object.keys(meta.shape as object)).toEqual(["note"]);
});

it("rejectFields throws on smuggled keys", () => {
expect(() =>
rejectFields(shape, { id: "1", role: "admin" }, dropMarked),
).toThrow(ValidationError);
expect(() =>
rejectFields(
shape,
{ id: "1", meta: { note: "n", secret: "s" } },
dropMarked,
),
).toThrow(/field is not allowed/);
});

it("parseFields rejects then validates the projection", () => {
expect(
parseFields(
shape,
{ id: "1", meta: { note: "hi" } },
{ reject: dropMarked, omit: dropMarked },
),
).toEqual({ id: "1", meta: { note: "hi" } });
expect(() =>
parseFields(
shape,
{ id: "1", role: "admin" },
{ reject: dropMarked, omit: dropMarked },
),
).toThrow(ValidationError);
});

it("omitFields projects through a var schema", () => {
const user = v.var("parse_user", {
schema: shape,
});
const projected = omitFields(user, dropMarked);
expect(projected.$var).toBe(true);
expect(
Object.keys(
asType((projected as { schema: unknown }).schema).shape as object,
).sort(),
).toEqual(["id", "meta"]);
});
});
5 changes: 3 additions & 2 deletions packages/experimental/src/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,8 +565,9 @@ export const serve = async (
const held = await spend(token);
c.capability = { ...held, entry: c.input.call };
}
// Wire edge: reject http.serverOnly fields before the target
// validates (object validate would otherwise strip them quietly).
// Wire edge: reject http.readonly / serverOnly fields before the
// target validates (object validate would otherwise strip them
// quietly).
const inputSchema = (target as FnDefination<any, any>).$schema?.input;
if (inputSchema !== undefined) {
rejectServerOnly(inputSchema, c.input.input, c.input.call);
Expand Down
15 changes: 15 additions & 0 deletions packages/experimental/src/fn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,21 @@ describe("v.fn call forms", () => {
expect(make({ ok: true })()).toEqual({ ok: true });
expect(() => make({ ok: "nope" })()).toThrow(/fnt\.split\.output/);
});

it("http.returned fields are stripped from output validation", async () => {
const { returned } = await import("./plugins/http/attrs");
const user = v.var("fnt_returned_user", {
schema: v.object({
id: v.string(),
password: returned(v.string()),
}),
});
const f = v.fn("fnt.returned", { output: user }, () => ({
id: "1",
password: "secret",
}));
expect(f()).toEqual({ id: "1" });
});
});

describe("omittable input", () => {
Expand Down
22 changes: 18 additions & 4 deletions packages/experimental/src/fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ import {
} from "./module";
import {
asType,
attrsOf,
type InferArgs,
type InferInput,
isVar,
type OutputSchemaOf,
omitFields,
outputContract,
type TypeDefination,
validate,
Expand Down Expand Up @@ -813,7 +815,17 @@ const defineFn = (

// Only the VALIDATION half of the output contract is checked on exit -
// a `{ def }`-only output is a documented promise, never a check.
const outputValidation = outputContract(options.output).validation;
// Fields marked `http.returned` are projected out so they never leave
// the process through this fn's result (direct in-process data on the
// handler's locals is unaffected).
const rawOutputValidation = outputContract(options.output).validation;
const outputValidation =
rawOutputValidation === undefined
? undefined
: omitFields(
rawOutputValidation,
(field) => attrsOf(field, "http")?.returned === true,
);
const errorTypes = declaredErrors
? Object.fromEntries(
Object.entries(declaredErrors).map(([tag, schema]) => [
Expand Down Expand Up @@ -1196,8 +1208,10 @@ const defineFn = (
);

// Exit contracts run after the body, whether or not it was async.
// Output validation both checks AND projects (so `http.returned`
// fields / undeclared keys leave through the validated shape).
const finish = (result: unknown) => {
const afterOutput = () => {
const afterOutput = (out: unknown) => {
if (bodyRan) {
for (const name of options.provides ?? []) {
if (missing(name)) {
Expand All @@ -1208,9 +1222,9 @@ const defineFn = (
}
}
}
return result;
return out;
};
if (outputValidation === undefined) return afterOutput();
if (outputValidation === undefined) return afterOutput(result);
return thenMaybe(
validate(asType(outputValidation), result, `${key}.output`),
afterOutput,
Expand Down
5 changes: 5 additions & 0 deletions packages/experimental/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,15 @@ export {
export {
type AttrBag,
attrsOf,
type FieldPred,
type InferArgs,
type InferInput,
type InferOutput,
type InferType,
omitFields,
type ParseFieldsOptions,
parseFields,
rejectFields,
type TypeDefination,
withAttrs,
} from "./schema";
Expand Down
Loading
Loading