Skip to content

Commit c2313e2

Browse files
committed
transform combinator
1 parent 4d6b88d commit c2313e2

12 files changed

Lines changed: 835 additions & 67 deletions

File tree

‎README.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,36 @@ Source precedence is explicit:
187187
CLI → environment → JavaScript values → schema default
188188
```
189189

190+
`transform()` groups a set of options and applies a transformation after their
191+
values have been captured. It takes the transformer first, followed by the
192+
options it scopes:
193+
194+
```ts
195+
const app = command(
196+
name("server"),
197+
checkpoint(),
198+
transform(
199+
(context) => ({
200+
secure: context.options.port > 0,
201+
}),
202+
option(name("port"), description("server port"), schema(z.number())),
203+
),
204+
);
205+
```
206+
207+
For a function transformer, the single context object contains:
208+
209+
- `context.options`, scoped to exactly the options declared inside the
210+
`transform()`.
211+
- `context.phase`, the active phase parameters, including their schemas.
212+
- `context.addIssue(issue)`, which adds a validation issue and prevents the
213+
parse from producing an intent.
214+
215+
The function returns fields to merge into the route model. A Standard Schema can
216+
be passed instead of a function. It receives the same scoped options and can
217+
return transformed fields or validation issues. With no scoped options, it
218+
receives an empty object.
219+
190220
## Pause without surrendering the type system
191221

192222
Sometimes the route cannot be fully configured, or even fully discovered, until

‎deno.lock‎

Lines changed: 45 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/insights.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ The detailed phase-binding design is recorded in [Binding](./binding.md).
9191
- The resolved route has a directly typed `model`.
9292
- The result also carries path-addressed `models` for every matched route.
9393
- Route identity and result projection are separate concerns.
94+
- A model transform is a batch operation: a transformer followed by the options
95+
it scopes. A function receives one object containing only those `options`, the
96+
active `phase`, and `addIssue()`. A Standard Schema receives the same scoped
97+
options. Both forms return fields that are merged into the model; issues from
98+
either form prevent the parse from producing an intent.
9499
- Namespacing is a mounting/address operation, not something plugin authors
95100
repeat locally.
96101
- Exact route and method values discriminate the result union.
@@ -151,7 +156,6 @@ The detailed phase-binding design is recorded in [Binding](./binding.md).
151156
These have not been promoted to invariants:
152157

153158
- Exact shadowed-source warning policy.
154-
- Holistic model transforms.
155159
- Final alias syntax.
156160
- Recursive-dynamic implementation details.
157161
- The exact route, path, and path-addressed model context exposed by an

‎examples/checkpoint-options.ts‎

Lines changed: 117 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,133 @@
1-
import { checkpoint, cli, command, description, name, option } from "../mod.ts";
2-
import { schema } from "../lib/param.ts";
1+
import {
2+
checkpoint,
3+
cli,
4+
command,
5+
description,
6+
name,
7+
option,
8+
schema,
9+
transform,
10+
version,
11+
} from "../mod.ts";
312
import type { ModelOf } from "../lib/types.ts";
413
import { z } from "zod";
514

615
export const app = command(
716
name("auth0"),
17+
description("Provision and inspect Auth0 tenants."),
18+
version("1.0.0"),
819
option(
9-
name("port"),
10-
description("server port"),
11-
cli(["--port", "-p"]),
12-
schema(z.number()),
13-
),
14-
option(
15-
name("domain"),
16-
description("server domain"),
17-
cli(["--domain"]),
20+
name("config"),
21+
description("JSON config path"),
22+
cli(["--config", "-c"]),
1823
schema(z.string()),
1924
),
25+
checkpoint(),
26+
transform(
27+
(context) => {
28+
let { domain, port, protocol } = context.options;
29+
let parsed = domain === undefined ? undefined : parseDomain(domain);
30+
31+
if (domain !== undefined && parsed === undefined) {
32+
context.addIssue({
33+
message: `domain must be an HTTP or HTTPS host, received ${domain}`,
34+
});
35+
}
36+
37+
if (
38+
parsed?.protocol !== undefined && protocol !== undefined &&
39+
parsed.protocol !== protocol
40+
) {
41+
context.addIssue({
42+
message:
43+
`domain protocol ${parsed.protocol} conflicts with protocol ${protocol}`,
44+
});
45+
}
46+
47+
if (
48+
parsed?.port !== undefined && port !== undefined && parsed.port !== port
49+
) {
50+
context.addIssue({
51+
message: `domain port ${parsed.port} conflicts with port ${port}`,
52+
});
53+
}
54+
55+
let resolvedProtocol = protocol ?? parsed?.protocol ??
56+
(port === 80 || parsed?.port === 80 ? "http" : "https");
57+
let resolvedPort = port ?? parsed?.port ??
58+
(resolvedProtocol === "https" ? 443 : 80);
59+
let host = parsed?.host ?? "localhost";
60+
61+
return {
62+
port: resolvedPort,
63+
domain: `${resolvedProtocol}://${host}:${resolvedPort}`,
64+
protocol: resolvedProtocol,
65+
};
66+
},
67+
option(
68+
name("port"),
69+
description("server port"),
70+
cli(["--port", "-p"]),
71+
schema(z.optional(z.number().int().min(1).max(65535))),
72+
),
73+
option(
74+
name("domain"),
75+
description("server domain"),
76+
cli(["--domain"]),
77+
schema(z.optional(z.string())),
78+
),
79+
option(
80+
name("protocol"),
81+
description("server protocol"),
82+
cli(["--protocol"]),
83+
schema(z.optional(z.enum(["http", "https"]))),
84+
),
85+
),
2086
option(
2187
name("audience"),
2288
description("Auth0 audience"),
2389
cli(["--audience"]),
2490
schema(z.string()),
2591
),
26-
option(
27-
name("clientID"),
28-
description("Auth0 client ID"),
29-
cli(["--client-id"]),
30-
schema(z.string()),
31-
),
32-
option(
33-
name("protocol"),
34-
description("server protocol"),
35-
cli(["--protocol"]),
36-
schema(z.enum(["http", "https"])),
37-
),
38-
checkpoint(),
39-
option(
40-
name("clientSecret"),
41-
description("client secret"),
42-
cli(["--client-secret"]),
43-
schema(z.string()),
44-
),
45-
option(
46-
name("scope"),
47-
description("OAuth scope"),
48-
cli(["--scope"]),
49-
schema(z.string()),
50-
),
51-
option(
52-
name("rulesDirectory"),
53-
description("rules directory"),
54-
cli(["--rules-directory"]),
55-
schema(z.string()),
56-
),
57-
option(
58-
name("connection"),
59-
description("Auth0 connection"),
60-
cli(["--connection"]),
61-
schema(z.string()),
62-
),
63-
option(
64-
name("config"),
65-
description("JSON config path"),
66-
cli(["--config", "-c"]),
67-
schema(z.string()),
68-
),
6992
);
7093

94+
type Protocol = "http" | "https";
95+
96+
type ParsedDomain = {
97+
host: string;
98+
port?: number;
99+
protocol?: Protocol;
100+
};
101+
102+
function parseDomain(value: string): ParsedDomain | undefined {
103+
let hasProtocol = /^[a-z][a-z\d+.-]*:\/\//i.test(value);
104+
let url: URL;
105+
106+
try {
107+
url = new URL(hasProtocol ? value : `http://${value}`);
108+
} catch {
109+
return undefined;
110+
}
111+
112+
if (
113+
!["http:", "https:"].includes(url.protocol) || url.pathname !== "/" ||
114+
url.search !== "" || url.hash !== "" || url.username !== "" ||
115+
url.password !== ""
116+
) {
117+
return undefined;
118+
}
119+
120+
return {
121+
host: url.hostname,
122+
port: url.port === "" ? undefined : Number(url.port),
123+
protocol: hasProtocol ? url.protocol.slice(0, -1) as Protocol : undefined,
124+
};
125+
}
126+
127+
// Production-use type: application code can use this inferred configuration shape.
71128
export type Configuration = ModelOf<typeof app>;
72129

130+
// Diagnostic-only assertions: these force TypeScript to materialize representative keys.
73131
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends
74132
(<T>() => T extends B ? 1 : 2)
75133
? (<T>() => T extends B ? 1 : 2) extends (<T>() => T extends A ? 1 : 2) ? true
@@ -79,8 +137,11 @@ type Assert<T extends true> = T;
79137
type ConfigIsPresent = Assert<
80138
Equal<"config" extends keyof Configuration ? true : false, true>
81139
>;
82-
type ConnectionIsPresent = Assert<
83-
Equal<"connection" extends keyof Configuration ? true : false, true>
140+
type PortIsPresent = Assert<
141+
Equal<"port" extends keyof Configuration ? true : false, true>
84142
>;
85143
type PortIsNumber = Assert<Equal<Configuration["port"], number>>;
86144
type ConfigIsString = Assert<Equal<Configuration["config"], string>>;
145+
type ProtocolIsEnum = Assert<
146+
Equal<Configuration["protocol"], "http" | "https">
147+
>;

‎lib/param.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function schema<T>(
5858
}));
5959
}
6060

61-
interface SchemaTransform<T> extends Transform {
61+
export interface SchemaTransform<T> extends Transform {
6262
readonly input: Param<string, unknown>;
6363
readonly output: this["input"] extends Param<infer N, unknown> ? Param<N, T>
6464
: never;

0 commit comments

Comments
 (0)