Skip to content

Commit 56bc136

Browse files
authored
Merge pull request #204 from ping-maxwell/feat/experimental-http-readonly-returned
feat(experimental): add http.readonly and http.returned field attrs
2 parents 61da7ff + 619a07b commit 56bc136

9 files changed

Lines changed: 638 additions & 119 deletions

File tree

packages/experimental/src/attrs.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { describe, expect, it } from "vitest";
22
import { ValidationError } from "./error";
33
import { v } from "./index";
4-
import { attrsOf, validate, withAttrs } from "./schema";
4+
import {
5+
asType,
6+
attrsOf,
7+
omitFields,
8+
parseFields,
9+
rejectFields,
10+
validate,
11+
withAttrs,
12+
} from "./schema";
513

614
describe("schema $attrs", () => {
715
it("withAttrs merges within a namespace and isolates namespaces", () => {
@@ -61,3 +69,69 @@ describe("schema $attrs", () => {
6169
).toBeDefined();
6270
});
6371
});
72+
73+
describe("omitFields / rejectFields / parseFields", () => {
74+
const dropMarked = (schema: unknown) =>
75+
attrsOf(schema, "http")?.readonly === true;
76+
const shape = v.object({
77+
id: v.string(),
78+
role: withAttrs(v.string(), "http", { readonly: true }),
79+
meta: v.object({
80+
note: v.string(),
81+
secret: withAttrs(v.string(), "http", { readonly: true }),
82+
}),
83+
});
84+
85+
it("omitFields projects nested objects", () => {
86+
const projected = asType(omitFields(shape, dropMarked));
87+
expect(Object.keys(projected.shape as object).sort()).toEqual([
88+
"id",
89+
"meta",
90+
]);
91+
const meta = asType((projected.shape as Record<string, unknown>).meta);
92+
expect(Object.keys(meta.shape as object)).toEqual(["note"]);
93+
});
94+
95+
it("rejectFields throws on smuggled keys", () => {
96+
expect(() =>
97+
rejectFields(shape, { id: "1", role: "admin" }, dropMarked),
98+
).toThrow(ValidationError);
99+
expect(() =>
100+
rejectFields(
101+
shape,
102+
{ id: "1", meta: { note: "n", secret: "s" } },
103+
dropMarked,
104+
),
105+
).toThrow(/field is not allowed/);
106+
});
107+
108+
it("parseFields rejects then validates the projection", () => {
109+
expect(
110+
parseFields(
111+
shape,
112+
{ id: "1", meta: { note: "hi" } },
113+
{ reject: dropMarked, omit: dropMarked },
114+
),
115+
).toEqual({ id: "1", meta: { note: "hi" } });
116+
expect(() =>
117+
parseFields(
118+
shape,
119+
{ id: "1", role: "admin" },
120+
{ reject: dropMarked, omit: dropMarked },
121+
),
122+
).toThrow(ValidationError);
123+
});
124+
125+
it("omitFields projects through a var schema", () => {
126+
const user = v.var("parse_user", {
127+
schema: shape,
128+
});
129+
const projected = omitFields(user, dropMarked);
130+
expect(projected.$var).toBe(true);
131+
expect(
132+
Object.keys(
133+
asType((projected as { schema: unknown }).schema).shape as object,
134+
).sort(),
135+
).toEqual(["id", "meta"]);
136+
});
137+
});

packages/experimental/src/capability.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -565,11 +565,12 @@ export const serve = async (
565565
const held = await spend(token);
566566
c.capability = { ...held, entry: c.input.call };
567567
}
568-
// Wire edge: reject http.serverOnly fields before the target
569-
// validates (object validate would otherwise strip them quietly).
568+
// Wire edge: reject http.readonly / serverOnly fields before the
569+
// target validates (object validate would otherwise strip them
570+
// quietly).
570571
const inputSchema = (target as FnDefination<any, any>).$schema?.input;
571572
if (inputSchema !== undefined) {
572-
rejectServerOnly(inputSchema, c.input.input, c.input.call);
573+
await rejectServerOnly(inputSchema, c.input.input, c.input.call);
573574
}
574575
return (target as (i: unknown, p: unknown) => unknown)(c.input.input, c);
575576
},

packages/experimental/src/fn.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,21 @@ describe("v.fn call forms", () => {
6565
expect(make({ ok: true })()).toEqual({ ok: true });
6666
expect(() => make({ ok: "nope" })()).toThrow(/fnt\.split\.output/);
6767
});
68+
69+
it("http.returned fields are stripped from output validation", async () => {
70+
const { returned } = await import("./plugins/http/attrs");
71+
const user = v.var("fnt_returned_user", {
72+
schema: v.object({
73+
id: v.string(),
74+
password: returned(v.string()),
75+
}),
76+
});
77+
const f = v.fn("fnt.returned", { output: user }, () => ({
78+
id: "1",
79+
password: "secret",
80+
}));
81+
expect(f()).toEqual({ id: "1" });
82+
});
6883
});
6984

7085
describe("omittable input", () => {

packages/experimental/src/fn.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ import {
3030
} from "./module";
3131
import {
3232
asType,
33+
attrsOf,
3334
type InferArgs,
3435
type InferInput,
3536
isVar,
3637
type OutputSchemaOf,
38+
omitFields,
3739
outputContract,
3840
type TypeDefination,
3941
validate,
@@ -813,7 +815,17 @@ const defineFn = (
813815

814816
// Only the VALIDATION half of the output contract is checked on exit -
815817
// a `{ def }`-only output is a documented promise, never a check.
816-
const outputValidation = outputContract(options.output).validation;
818+
// Fields marked `http.returned` are projected out so they never leave
819+
// the process through this fn's result (direct in-process data on the
820+
// handler's locals is unaffected).
821+
const rawOutputValidation = outputContract(options.output).validation;
822+
const outputValidation =
823+
rawOutputValidation === undefined
824+
? undefined
825+
: omitFields(
826+
rawOutputValidation,
827+
(field) => attrsOf(field, "http")?.returned === true,
828+
);
817829
const errorTypes = declaredErrors
818830
? Object.fromEntries(
819831
Object.entries(declaredErrors).map(([tag, schema]) => [
@@ -1196,8 +1208,10 @@ const defineFn = (
11961208
);
11971209

11981210
// Exit contracts run after the body, whether or not it was async.
1211+
// Output validation both checks AND projects (so `http.returned`
1212+
// fields / undeclared keys leave through the validated shape).
11991213
const finish = (result: unknown) => {
1200-
const afterOutput = () => {
1214+
const afterOutput = (out: unknown) => {
12011215
if (bodyRan) {
12021216
for (const name of options.provides ?? []) {
12031217
if (missing(name)) {
@@ -1208,9 +1222,9 @@ const defineFn = (
12081222
}
12091223
}
12101224
}
1211-
return result;
1225+
return out;
12121226
};
1213-
if (outputValidation === undefined) return afterOutput();
1227+
if (outputValidation === undefined) return afterOutput(result);
12141228
return thenMaybe(
12151229
validate(asType(outputValidation), result, `${key}.output`),
12161230
afterOutput,

packages/experimental/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,15 @@ export {
145145
export {
146146
type AttrBag,
147147
attrsOf,
148+
type FieldPred,
148149
type InferArgs,
149150
type InferInput,
150151
type InferOutput,
151152
type InferType,
153+
omitFields,
154+
type ParseFieldsOptions,
155+
parseFields,
156+
rejectFields,
152157
type TypeDefination,
153158
withAttrs,
154159
} from "./schema";

0 commit comments

Comments
 (0)