@gjermundgaraba/effect-actions 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -50
- package/dist/Action.d.mts +286 -79
- package/dist/Action.mjs +125 -23
- package/dist/ActionCli.d.mts +102 -31
- package/dist/ActionCli.mjs +114 -16
- package/dist/ActionHttp.d.mts +170 -51
- package/dist/ActionHttp.mjs +130 -79
- package/dist/ActionMcp.d.mts +71 -21
- package/dist/ActionMcp.mjs +231 -80
- package/dist/ActionToolkit.d.mts +56 -27
- package/dist/ActionToolkit.mjs +16 -5
- package/dist/Authentication.d.mts +129 -33
- package/dist/Authentication.mjs +231 -38
- package/dist/Testing.d.mts +84 -57
- package/dist/Testing.mjs +121 -79
- package/dist/internal/actions.d.mts +5 -25
- package/dist/internal/actions.mjs +75 -27
- package/dist/internal/authentication.d.mts +102 -0
- package/dist/internal/authentication.mjs +11 -0
- package/dist/internal/call.d.mts +12 -0
- package/dist/internal/call.mjs +12 -0
- package/dist/internal/cli.d.mts +36 -22
- package/dist/internal/cli.mjs +311 -33
- package/dist/internal/client.d.mts +43 -0
- package/dist/internal/client.mjs +25 -0
- package/dist/internal/console.mjs +77 -0
- package/dist/internal/declared.d.mts +13 -0
- package/dist/internal/declared.mjs +34 -0
- package/dist/internal/errors.d.mts +77 -0
- package/dist/internal/errors.mjs +79 -0
- package/dist/internal/implementation.d.mts +171 -41
- package/dist/internal/implementation.mjs +147 -29
- package/dist/internal/mcp.d.mts +14 -0
- package/dist/internal/mcp.mjs +182 -0
- package/dist/internal/media.d.mts +8 -0
- package/dist/internal/media.mjs +26 -0
- package/dist/internal/memory.d.mts +6 -0
- package/dist/internal/memory.mjs +38 -0
- package/dist/internal/refusal.mjs +67 -0
- package/dist/internal/schema-errors.mjs +20 -0
- package/dist/internal/tools.mjs +27 -57
- package/docs/Action.md +403 -73
- package/docs/ActionCli.md +281 -51
- package/docs/ActionHttp.md +267 -82
- package/docs/ActionMcp.md +232 -102
- package/docs/ActionToolkit.md +99 -35
- package/docs/Authentication.md +369 -62
- package/docs/CONTEXT.md +24 -36
- package/docs/README.md +29 -73
- package/docs/Testing.md +154 -77
- package/docs/guarantees.md +82 -43
- package/docs/setup.md +95 -27
- package/package.json +12 -20
- package/dist/ActionCatalog.d.mts +0 -30
- package/dist/ActionCatalog.mjs +0 -39
- package/dist/ActionCliClient.d.mts +0 -48
- package/dist/ActionCliClient.mjs +0 -43
- package/dist/ActionGroup.d.mts +0 -44
- package/dist/ActionGroup.mjs +0 -35
- package/dist/ActionHttpClient.d.mts +0 -33
- package/dist/ActionHttpClient.mjs +0 -16
- package/dist/TestingClient.d.mts +0 -17
- package/dist/TestingClient.mjs +0 -22
- package/dist/internal/fetchClient.d.mts +0 -6
- package/dist/internal/fetchClient.mjs +0 -8
- package/dist/internal/tools.d.mts +0 -22
- package/docs/ActionCatalog.md +0 -54
- package/docs/ActionCliClient.md +0 -63
- package/docs/ActionGroup.md +0 -128
- package/docs/ActionHttpClient.md +0 -80
package/dist/internal/cli.mjs
CHANGED
|
@@ -1,50 +1,328 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { InvalidInput } from "./errors.mjs";
|
|
2
|
+
import { assertDistinct, literalValues, members, projectedErrors, unsuspended } from "./actions.mjs";
|
|
3
|
+
import { logToStderr } from "./console.mjs";
|
|
4
|
+
import { Console, Effect, Option, Predicate, Record, Runtime, Schema, SchemaAST } from "effect";
|
|
5
|
+
import { CliError, Command, Flag, Param } from "effect/cli";
|
|
3
6
|
//#region src/internal/cli.ts
|
|
4
|
-
|
|
7
|
+
/**
|
|
8
|
+
* `getUser` as a command or flag name: `get-user`; `getHTTPUser`: `get-http-user`; `_id`:
|
|
9
|
+
* `id`, since a flag already starts with its dashes. A name of dashes and underscores alone,
|
|
10
|
+
* which would be left empty, is kept as it is.
|
|
11
|
+
*/
|
|
12
|
+
const kebab = (name) => {
|
|
13
|
+
const kebabbed = name.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
|
|
14
|
+
return kebabbed === "" ? name : kebabbed;
|
|
15
|
+
};
|
|
5
16
|
/**
|
|
6
17
|
* A command with a renderer takes `--json` as a flag of its own, so nothing is
|
|
7
18
|
* claimed tree-wide and a host's `--json`, global or not, is never contested.
|
|
8
19
|
* Without a renderer the output is JSON already and there is no flag.
|
|
9
20
|
*/
|
|
10
21
|
const jsonFlag = Flag.Boolean("json").pipe(Flag.withDefault(false), Flag.withDescription("Print machine-readable JSON"));
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
22
|
+
/** The kinds of JSON value there are. */
|
|
23
|
+
const jsonKinds = [
|
|
24
|
+
"null",
|
|
25
|
+
"boolean",
|
|
26
|
+
"number",
|
|
27
|
+
"string",
|
|
28
|
+
"array",
|
|
29
|
+
"object"
|
|
30
|
+
];
|
|
31
|
+
/** The kind of a JSON value, or of a literal's, which JSON has no bigint for. */
|
|
32
|
+
const kindOf = (value) => value === null ? "null" : Predicate.isBoolean(value) ? "boolean" : Predicate.isNumber(value) ? "number" : Predicate.isString(value) ? "string" : Array.isArray(value) ? "array" : "object";
|
|
33
|
+
/**
|
|
34
|
+
* The kinds of JSON value an encoded type accepts at its top, each member's of a union and a
|
|
35
|
+
* suspended type's: its checks and what it nests aside, which the action's schema decodes.
|
|
36
|
+
* A type of no fixed kind accepts all.
|
|
37
|
+
*/
|
|
38
|
+
const kindsOf = (ast) => members(ast).flatMap((member) => {
|
|
39
|
+
if (SchemaAST.isLiteral(member)) return [kindOf(member.literal)];
|
|
40
|
+
if (SchemaAST.isEnum(member)) return member.enums.map(([, value]) => kindOf(value));
|
|
41
|
+
if (SchemaAST.isNull(member)) return ["null"];
|
|
42
|
+
if (SchemaAST.isBoolean(member)) return ["boolean"];
|
|
43
|
+
if (SchemaAST.isNumber(member)) return ["number"];
|
|
44
|
+
if (SchemaAST.isString(member) || SchemaAST.isTemplateLiteral(member)) return ["string"];
|
|
45
|
+
if (SchemaAST.isArrays(member)) return ["array"];
|
|
46
|
+
if (SchemaAST.isObjects(member)) return ["object"];
|
|
47
|
+
return jsonKinds;
|
|
15
48
|
});
|
|
16
49
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
50
|
+
* A flag's text as the JSON it holds when the field's `encoded` type accepts that kind of
|
|
51
|
+
* value, or else as itself, for the action's schema to decode: for a number `21` parses,
|
|
52
|
+
* while for `"auto" | string` the text `true` stays text. Only the kind is checked, so JSON
|
|
53
|
+
* breaking a rule of the schema, such as a maximum length, stays JSON, and the action's
|
|
54
|
+
* schema reports the rule and the path to what breaks it.
|
|
19
55
|
*/
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
56
|
+
const jsonOrText = (encoded) => {
|
|
57
|
+
const accepted = new Set(kindsOf(encoded));
|
|
58
|
+
return Schema.Union([Schema.fromJsonString(Schema.Json).check(Schema.makeFilter((json) => accepted.has(kindOf(json)))), Schema.String]);
|
|
59
|
+
};
|
|
60
|
+
/** The strings a union of string literals or a string enum accepts, if it is one. */
|
|
61
|
+
const choices = (ast) => {
|
|
62
|
+
const accepted = members(ast).flatMap(literalValues);
|
|
63
|
+
return accepted.every(Predicate.isString) ? accepted : void 0;
|
|
27
64
|
};
|
|
28
65
|
/**
|
|
29
|
-
* The
|
|
30
|
-
*
|
|
66
|
+
* The native flag or argument parsing one field's encoded JSON value: a string or boolean
|
|
67
|
+
* for those, a choice for string literals and string enums, and JSON or text for anything
|
|
68
|
+
* else, numbers included. A template literal is a string; the action's schema checks its
|
|
69
|
+
* pattern. A suspended type is read as the type it stands for. JSON or text, a flag's or an
|
|
70
|
+
* argument's, is shown as `value`, not as the string it is parsed from.
|
|
31
71
|
*/
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
72
|
+
const valueParam = (kind, name, field) => {
|
|
73
|
+
const encoded = unsuspended(field);
|
|
74
|
+
const literals = choices(encoded);
|
|
75
|
+
if (literals !== void 0) return Param.Literals(kind, name, literals);
|
|
76
|
+
if (SchemaAST.isString(encoded) || SchemaAST.isTemplateLiteral(encoded)) return Param.String(kind, name);
|
|
77
|
+
if (SchemaAST.isBoolean(encoded)) return Param.Boolean(kind, name);
|
|
78
|
+
return Param.String(kind, name).pipe(Param.withSchema(jsonOrText(encoded)), Param.withMetavar("value"));
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* An optional field's value without the absence `Schema.optional` adds to it: `undefined`
|
|
82
|
+
* declared, `null` encoded. Omitting the flag already leaves the field out, so
|
|
83
|
+
* `optional(Schema.String)` takes a plain string.
|
|
84
|
+
*/
|
|
85
|
+
const present = (ast) => {
|
|
86
|
+
const members = SchemaAST.isUnion(ast) ? ast.types.filter((member) => !SchemaAST.isNull(member) && !SchemaAST.isUndefined(member)) : [];
|
|
87
|
+
return members.length === 1 && members[0] !== void 0 ? members[0] : ast;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Whether `Schema.optional` added the field's JSON `null`: encoded without the JSON codec,
|
|
91
|
+
* its value admits `undefined` but not `null`. A `null` of the field's own schema, as
|
|
92
|
+
* `NullOr` or `OptionFromNullOr` encode, stays.
|
|
93
|
+
*/
|
|
94
|
+
const addsNull = (plain) => {
|
|
95
|
+
const types = members(plain);
|
|
96
|
+
return types.some(SchemaAST.isUndefined) && !types.some(SchemaAST.isNull);
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* The element of an encoded array a flag repeats, and how many times it must: an array of
|
|
100
|
+
* strings or numbers, choices included, whose fixed first elements, if any, are its rest's,
|
|
101
|
+
* as `Schema.NonEmptyArray` writes one. `undefined` for any other array, which takes JSON.
|
|
102
|
+
*/
|
|
103
|
+
const repeated = (ast) => {
|
|
104
|
+
const encoded = unsuspended(ast);
|
|
105
|
+
if (!SchemaAST.isArrays(encoded)) return void 0;
|
|
106
|
+
const [element, ...after] = encoded.rest;
|
|
107
|
+
if (element === void 0 || after.length > 0 || !encoded.elements.every((first) => first === element) || !kindsOf(element).every((kind) => kind === "string" || kind === "number")) return;
|
|
108
|
+
return {
|
|
109
|
+
element,
|
|
110
|
+
min: encoded.elements.length
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
/** The occurrence of a repeated flag that adds no element, so `--tag '[]'` alone is `[]`. */
|
|
114
|
+
const empty = "[]";
|
|
115
|
+
/**
|
|
116
|
+
* A field's flag or argument, `None` when omitted, from its JSON encoding and its plain one.
|
|
117
|
+
* A required field's is required, so the parser reports it missing; a required boolean flag
|
|
118
|
+
* is a switch instead: omitted, it is `false`, as a switch reads. A boolean argument takes
|
|
119
|
+
* `true` or `false`. A flag or argument of an array of strings or numbers is repeated, one
|
|
120
|
+
* element per occurrence but `[]`, which adds none, and a choice among the element's: none
|
|
121
|
+
* is `[]`, or leaves an optional field out, which `[]` gives instead.
|
|
122
|
+
*/
|
|
123
|
+
const fieldParam = (kind, name, encoded, plain) => {
|
|
124
|
+
const optional = SchemaAST.isOptional(encoded);
|
|
125
|
+
const value = optional && plain !== void 0 && addsNull(plain) ? present(encoded) : encoded;
|
|
126
|
+
const repeats = repeated(value);
|
|
127
|
+
if (repeats !== void 0) {
|
|
128
|
+
const literals = choices(unsuspended(repeats.element));
|
|
129
|
+
const elements = literals === void 0 ? valueParam(kind, name, repeats.element) : Param.Literals(kind, name, [...literals, empty]);
|
|
130
|
+
const listed = (values) => Option.some(values.filter((value) => value !== empty));
|
|
131
|
+
return {
|
|
132
|
+
param: optional ? Param.variadic(elements).pipe(Param.map((values) => values.length === 0 ? Option.none() : listed(values))) : Param.variadic(elements, { min: repeats.min }).pipe(Param.map(listed)),
|
|
133
|
+
repeats: true
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (optional) return {
|
|
137
|
+
param: Param.optional(valueParam(kind, name, value)),
|
|
138
|
+
repeats: false
|
|
139
|
+
};
|
|
140
|
+
return {
|
|
141
|
+
param: kind === Param.flagKind && SchemaAST.isBoolean(unsuspended(encoded)) ? Flag.Boolean(name).pipe(Flag.withDefault(false), Flag.map(Option.some)) : valueParam(kind, name, encoded).pipe(Param.map(Option.some)),
|
|
142
|
+
repeats: false
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* The fields of a struct, or of a class, which declares them as its one type parameter, by name.
|
|
147
|
+
*/
|
|
148
|
+
const fieldsOf = (ast) => {
|
|
149
|
+
const fields = SchemaAST.isDeclaration(ast) ? ast.typeParameters[0] : ast;
|
|
150
|
+
return new Map(fields !== void 0 && SchemaAST.isObjects(fields) ? fields.propertySignatures.map((property) => [property.name, property.type]) : []);
|
|
151
|
+
};
|
|
152
|
+
/** A struct, or a class's, transformed as a whole, as `Schema.encodeKeys` transforms one. */
|
|
153
|
+
const transformedWhole = (ast) => (SchemaAST.isDeclaration(ast) ? ast.typeParameters[0] : ast)?.encoding !== void 0;
|
|
154
|
+
/** A field's description: its own, or the first a suspension it stands for has. */
|
|
155
|
+
const descriptionOf = (ast) => SchemaAST.resolveDescription(ast) ?? (SchemaAST.isSuspend(ast) ? descriptionOf(ast.thunk()) : void 0);
|
|
156
|
+
/**
|
|
157
|
+
* One flag or argument per top-level field of a struct input, named after the field in
|
|
158
|
+
* kebab case, parsed as its encoded value and described by its declared schema: an
|
|
159
|
+
* argument for a field listed in `positional`, a flag otherwise. A field required once
|
|
160
|
+
* encoded is required; the action's schema still decodes what they parse.
|
|
161
|
+
*/
|
|
162
|
+
const fieldParams = (encoded, input, positional, aliases) => {
|
|
163
|
+
const plain = fieldsOf(SchemaAST.toEncoded(input));
|
|
164
|
+
const described = fieldsOf(transformedWhole(input) ? encoded : SchemaAST.toType(input));
|
|
165
|
+
return encoded.propertySignatures.map((property) => {
|
|
166
|
+
const field = String(property.name);
|
|
167
|
+
const declaredField = described.get(property.name);
|
|
168
|
+
const kind = positional.includes(field) ? Param.argumentKind : Param.flagKind;
|
|
169
|
+
const { param, repeats } = fieldParam(kind, kebab(field), property.type, plain.get(property.name));
|
|
170
|
+
const alias = kind === Param.flagKind ? aliases[field] : void 0;
|
|
171
|
+
const aliased = alias === void 0 ? param : Param.withAlias(param, alias);
|
|
172
|
+
const documented = declaredField ?? property.type;
|
|
173
|
+
const description = descriptionOf(documented) ?? descriptionOf(present(documented));
|
|
174
|
+
return {
|
|
175
|
+
field,
|
|
176
|
+
param: description === void 0 ? aliased : Param.withDescription(aliased, description),
|
|
177
|
+
optional: SchemaAST.isOptional(property.type),
|
|
178
|
+
repeats
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* The positional fields in order, checked against the fields there are: each one a field,
|
|
184
|
+
* listed once, and every required one before any optional one, as a parser reads them.
|
|
185
|
+
*/
|
|
186
|
+
const positionalOrder = (params, positional) => {
|
|
187
|
+
assertDistinct("positional argument", positional, (field) => field);
|
|
188
|
+
const ordered = positional.map((field) => {
|
|
189
|
+
const found = params.find((param) => param.field === field);
|
|
190
|
+
if (found === void 0) throw new Error(`Not an input field: ${field}`);
|
|
191
|
+
return found;
|
|
192
|
+
});
|
|
193
|
+
const firstOptional = ordered.findIndex((param) => param.optional);
|
|
194
|
+
const misplaced = ordered.slice(firstOptional === -1 ? ordered.length : firstOptional).find((param) => !param.optional);
|
|
195
|
+
if (misplaced !== void 0) throw new Error(`Required positional argument after an optional one: ${misplaced.field}`);
|
|
196
|
+
const greedy = ordered.slice(0, -1).find((param) => param.repeats);
|
|
197
|
+
if (greedy !== void 0) throw new Error(`Repeated positional argument before another one: ${greedy.field}`);
|
|
198
|
+
return ordered;
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* `--input`, the whole encoded input as JSON, or text, for an input that is not a struct of
|
|
202
|
+
* fields. It is never required: left off, it is `{}`, which the action's schema decodes
|
|
203
|
+
* when the command runs, never when it is built.
|
|
204
|
+
*/
|
|
205
|
+
const inputFlag = (encoded) => Flag.String("input").pipe(Flag.withSchema(jsonOrText(encoded)), Flag.withDescription("Whole action input as JSON; omitted, {}"), Flag.optional);
|
|
206
|
+
/**
|
|
207
|
+
* What a success prints on stdout: its JSON, or `render`'s text. It is validated and encoded
|
|
208
|
+
* first, so human output cannot conceal an invalid success, which is a defect, as on a server.
|
|
209
|
+
* An action that returns nothing prints nothing, rather than its encoding, `null`.
|
|
210
|
+
*/
|
|
211
|
+
const output = (action, value, render) => Effect.map(Effect.orDie(Schema.encodeEffect(Schema.toCodecJson(action.success))(value)), (encoded) => render === void 0 && SchemaAST.isVoid(action.success.ast) ? Option.none() : Option.some(render === void 0 ? JSON.stringify(encoded, null, 2) : render(value)));
|
|
212
|
+
/**
|
|
213
|
+
* What a command fails with when its action fails: Effect CLI's own error for a handler's
|
|
214
|
+
* failure, which `Command.run` prints on stderr and marks reported, so `runMain` does not
|
|
215
|
+
* print it again. Its `cause` is the action's failure. The process exits with the cause's
|
|
216
|
+
* `Runtime.errorExitCode`, 1 by default.
|
|
217
|
+
*/
|
|
218
|
+
var UserError = class extends CliError.UserError {
|
|
219
|
+
/**
|
|
220
|
+
* The action's failure, as Effect names an error's nested one, so the native
|
|
221
|
+
* `Effect.catchReason("UserError", "UserNotFound", f)` and `catchReasons` match it by tag.
|
|
222
|
+
*/
|
|
223
|
+
get reason() {
|
|
224
|
+
return this.cause;
|
|
225
|
+
}
|
|
226
|
+
get [Runtime.errorExitCode]() {
|
|
227
|
+
return Runtime.getErrorExitCode(this.cause);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
/** `error`'s own `_tag` or `message`, when it is a string. */
|
|
231
|
+
const text = (error, key) => {
|
|
232
|
+
if (!Predicate.hasProperty(error, key)) return void 0;
|
|
233
|
+
const value = error[key];
|
|
234
|
+
return Predicate.isString(value) ? value : void 0;
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* A failure no projected schema encodes, without a stack: its tag or an error's name, and
|
|
238
|
+
* its message, or the failure itself when it is a string, then each cause's, so a transport
|
|
239
|
+
* error keeps the refused connection beneath it, up to one already described, as a cycle
|
|
240
|
+
* repeats it. Its other fields are left out, a plain object's `name` too: no schema says
|
|
241
|
+
* they are safe to show, and a builder's failure may hold a connection string.
|
|
242
|
+
*/
|
|
243
|
+
const described = (error, seen = /* @__PURE__ */ new Set()) => {
|
|
244
|
+
const name = text(error, "_tag") ?? (error instanceof Error ? error.name : "Error");
|
|
245
|
+
const message = Predicate.isString(error) ? error : text(error, "message") ?? "";
|
|
246
|
+
const named = message === "" ? name : `${name}: ${message}`;
|
|
247
|
+
seen.add(error);
|
|
248
|
+
return Predicate.hasProperty(error, "cause") && error.cause !== void 0 && !seen.has(error.cause) ? `${named}: ${described(error.cause, seen)}` : named;
|
|
249
|
+
};
|
|
250
|
+
/**
|
|
251
|
+
* The `UserError` of each failure of `action`, its message the JSON HTTP sends for it: encoded
|
|
252
|
+
* by the first schema that takes it among the built-in errors, the action's and the
|
|
253
|
+
* surface's own `errors`, as an Effect, as HTTP encodes it. A failure none takes, such as a
|
|
254
|
+
* builder's or the transport's, is described instead.
|
|
255
|
+
*/
|
|
256
|
+
const failureOf = (action, errors) => {
|
|
257
|
+
const encode = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.toCodecJson(Schema.Union(projectedErrors(action, errors)))));
|
|
258
|
+
return (error) => encode(error).pipe(Effect.orElseSucceed(() => described(error)), Effect.flatMap((userMessage) => Effect.fail(new UserError({
|
|
259
|
+
cause: error,
|
|
260
|
+
userMessage
|
|
261
|
+
}))));
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* The action's input as native flags: one per field of a struct input, named after it,
|
|
265
|
+
* or `--input` taking the whole input as JSON otherwise. The `positional` fields of a
|
|
266
|
+
* struct are arguments instead, in that order.
|
|
267
|
+
*/
|
|
268
|
+
const inputConfig = (action, positional, aliases) => {
|
|
37
269
|
const codec = Schema.toCodecJson(action.input);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
270
|
+
const encoded = unsuspended(SchemaAST.toEncoded(codec.ast));
|
|
271
|
+
const decode = Schema.decodeUnknownEffect(codec, { onExcessProperty: "error" });
|
|
272
|
+
if (SchemaAST.isObjects(encoded) && encoded.indexSignatures.every((signature) => SchemaAST.isNever(signature.type))) {
|
|
273
|
+
const params = fieldParams(encoded, unsuspended(action.input.ast), positional, aliases);
|
|
274
|
+
const flagged = new Set(params.map(({ field }) => field).filter((field) => !positional.includes(field)));
|
|
275
|
+
const stray = Object.keys(aliases).find((field) => !flagged.has(field));
|
|
276
|
+
if (stray !== void 0) throw new Error(`Not a flag's input field: ${stray}`);
|
|
277
|
+
return {
|
|
278
|
+
flags: Object.fromEntries(params.flatMap(({ field, param }) => positional.includes(field) ? [] : [[field, param]])),
|
|
279
|
+
positional: positionalOrder(params, positional),
|
|
280
|
+
decode: (parsed) => decode(Record.getSomes(parsed))
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (positional.length > 0) throw new Error(`Positional arguments need named input fields: ${positional.join(", ")}`);
|
|
284
|
+
if (Object.keys(aliases).length > 0) throw new Error(`Aliases need named input fields: ${Object.keys(aliases).join(", ")}`);
|
|
285
|
+
return {
|
|
286
|
+
flags: { input: inputFlag(encoded) },
|
|
287
|
+
positional: [],
|
|
288
|
+
decode: (parsed) => decode(Option.getOrElse(parsed["input"] ?? Option.none(), () => ({})))
|
|
289
|
+
};
|
|
42
290
|
};
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
291
|
+
/**
|
|
292
|
+
* One native command around an action-bound operation, its flags and positional
|
|
293
|
+
* arguments derived from the action's input. A field's flag shadows a global flag of the
|
|
294
|
+
* same name; two flags of the command itself with one name are refused when it is built.
|
|
295
|
+
* Input that does not decode is `InvalidInput`, as over HTTP, and the operation writes its
|
|
296
|
+
* logs and console output to stderr, since stdout carries the result. Every failure is a
|
|
297
|
+
* `UserError`, encoded with the surface's `errors` too, such as a binding's.
|
|
298
|
+
*/
|
|
299
|
+
const command = (action, execute, options, errors = []) => {
|
|
300
|
+
const name = options?.name ?? kebab(action.name);
|
|
301
|
+
const render = options?.render;
|
|
302
|
+
const { flags, positional, decode } = inputConfig(action, options?.positional ?? [], options?.aliases ?? {});
|
|
303
|
+
const failure = failureOf(action, errors);
|
|
304
|
+
assertDistinct("flag", [
|
|
305
|
+
...Object.keys(flags).map((field) => [`--${kebab(field)}`, `field ${field}`]),
|
|
306
|
+
...render === void 0 ? [] : [["--json", "render's --json"]],
|
|
307
|
+
...Object.entries(options?.aliases ?? {}).flatMap(([field, alias]) => Predicate.isString(alias) ? [[`--${alias.replace(/^-+/, "")}`, `alias of field ${field}`]] : [])
|
|
308
|
+
], ([flag]) => flag, ([, claimant]) => claimant);
|
|
309
|
+
const args = positional.map(({ param }) => param);
|
|
310
|
+
const parsedFields = (input, values) => ({
|
|
311
|
+
...input,
|
|
312
|
+
...Object.fromEntries(positional.map(({ field }, index) => [field, values[index] ?? Option.none()]))
|
|
313
|
+
});
|
|
314
|
+
const run = (parsed, rendered) => decode(parsed).pipe(Effect.mapError(InvalidInput.fromSchemaError), Effect.flatMap(execute), Effect.catch(failure), Effect.flatMap((value) => output(action, value, rendered)), logToStderr, Effect.flatMap(Option.match({
|
|
315
|
+
onNone: () => Effect.void,
|
|
316
|
+
onSome: Console.log
|
|
317
|
+
})));
|
|
318
|
+
return (render === void 0 ? Command.make(name, {
|
|
319
|
+
input: flags,
|
|
320
|
+
args
|
|
321
|
+
}, ({ input, args: values }) => run(parsedFields(input, values), void 0)) : Command.make(name, {
|
|
322
|
+
input: flags,
|
|
323
|
+
args,
|
|
324
|
+
json: jsonFlag
|
|
325
|
+
}, ({ input, args: values, json }) => run(parsedFields(input, values), json ? void 0 : render))).pipe(Command.withDescription(action.description));
|
|
48
326
|
};
|
|
49
327
|
//#endregion
|
|
50
|
-
export { command };
|
|
328
|
+
export { UserError, command, kebab };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { BuiltIns } from "./errors.mjs";
|
|
2
|
+
import "./implementation.mjs";
|
|
3
|
+
import { Call } from "./call.mjs";
|
|
4
|
+
import { Action_d_exports, Any, Errors } from "../Action.mjs";
|
|
5
|
+
import { Any as Any$1, VerifierError } from "./authentication.mjs";
|
|
6
|
+
import { Effect, Schema } from "effect";
|
|
7
|
+
import { HttpClient, HttpClientError } from "effect/http";
|
|
8
|
+
import { HttpApi, HttpApiClient } from "effect/http-api";
|
|
9
|
+
//#region src/internal/client.d.ts
|
|
10
|
+
/**
|
|
11
|
+
* The native `HttpApiClient.make` options except `transformResponse`: `baseUrl` and
|
|
12
|
+
* `transformClient`. `transformResponse` may change a call's success, failure or
|
|
13
|
+
* services, which the method types cannot follow.
|
|
14
|
+
*/
|
|
15
|
+
export type Options = Omit<NonNullable<Parameters<typeof HttpApiClient.make>[1]>, "transformResponse">;
|
|
16
|
+
/** An error schema the binding declares on every endpoint. */
|
|
17
|
+
type BindingError = Errors[number];
|
|
18
|
+
/**
|
|
19
|
+
* What one call of `A` fails with: a declared error value (the action's own, one `E` of the
|
|
20
|
+
* binding's, or a built-in `InvalidInput`, `Unauthenticated` or `Forbidden`), a native
|
|
21
|
+
* `HttpClientError` for a failed request or an undeclared answer, or a `SchemaError` when
|
|
22
|
+
* the success does not decode. Input that does not encode is `InvalidInput`, and nothing is sent.
|
|
23
|
+
*/
|
|
24
|
+
export type MethodError<A extends Any, E extends BindingError = never> = A["error"][number]["Type"] | E["Type"] | BuiltIns["Type"] | HttpClientError.HttpClientError | Schema.SchemaError;
|
|
25
|
+
/** One action as an Effect of its decoded success, failing with `MethodError`. */
|
|
26
|
+
type Method<A extends Any, E extends BindingError> = Call<A, Effect.Effect<A["success"]["Type"], MethodError<A, E>>>;
|
|
27
|
+
/**
|
|
28
|
+
* Any HTTP binding. The API is a native constraint here because native groups are
|
|
29
|
+
* invariant in their endpoints; `actions` carries the types.
|
|
30
|
+
*/
|
|
31
|
+
export interface AnyHttp {
|
|
32
|
+
readonly authentication?: Any$1 | undefined;
|
|
33
|
+
readonly actions: ReadonlyArray<Any>;
|
|
34
|
+
readonly error: ReadonlyArray<BindingError>;
|
|
35
|
+
readonly prefix: `/${string}`;
|
|
36
|
+
readonly api: HttpApi.Constraint;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Every action of the binding `H`, as `client.<action>(input)`: a protected one may also fail
|
|
40
|
+
* with what its descriptor's verifier declares.
|
|
41
|
+
*/
|
|
42
|
+
export type Client<H extends AnyHttp> = { readonly [A in H["actions"][number] as A["name"]]: Method<A, H["error"][number] | VerifierError<A, H["authentication"]>>; };
|
|
43
|
+
//#endregion
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { InvalidInput } from "./errors.mjs";
|
|
2
|
+
import { inputOf } from "./call.mjs";
|
|
3
|
+
import { Effect, Schema } from "effect";
|
|
4
|
+
import { HttpApiClient } from "effect/http-api";
|
|
5
|
+
//#region src/internal/client.ts
|
|
6
|
+
/** Refuse an action a binding of `actions` does not hold, matched by identity. */
|
|
7
|
+
const assertInBinding = (actions, action) => {
|
|
8
|
+
if (!actions.includes(action)) throw new Error(`Action "${action.name}" is not in this HTTP binding`);
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Build the native client of a binding once and look up each action's method, taking the
|
|
12
|
+
* action's input directly. Every binding API is a native `HttpApi` of one top-level
|
|
13
|
+
* group built from `http.actions`, so each action has a native method of its name.
|
|
14
|
+
*/
|
|
15
|
+
const methods = (http, options = {}) => Effect.map(HttpApiClient.make(http.api, options), (client) => {
|
|
16
|
+
const native = client;
|
|
17
|
+
return (action) => {
|
|
18
|
+
const method = native[action.name];
|
|
19
|
+
if (method === void 0) throw new Error(`No client method for ${action.name}`);
|
|
20
|
+
const encode = Schema.encodeUnknownEffect(Schema.toCodecJson(action.input), { errors: "all" });
|
|
21
|
+
return (...args) => inputOf(action, args).pipe(Effect.tap(encode), Effect.mapError(InvalidInput.fromSchemaError), Effect.flatMap((payload) => method({ payload })));
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
//#endregion
|
|
25
|
+
export { assertInBinding, methods };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Clock, Console, Effect, Function, Predicate } from "effect";
|
|
2
|
+
//#region src/internal/console.ts
|
|
3
|
+
/**
|
|
4
|
+
* The console of code whose program's stdout carries data: a stdio server's protocol, or a
|
|
5
|
+
* CLI command's result. Every method writes through `console.error` alone, so every console
|
|
6
|
+
* logger, `Console.log` and the default logger alike, leaves stdout intact, and so do
|
|
7
|
+
* counters, timers and group labels, which Node's console prints on stdout. A counter or a
|
|
8
|
+
* timer prints its label as given, never as a format, then its count or the milliseconds
|
|
9
|
+
* since it started, read on `clock` and rounded to three decimals; a group prints its label
|
|
10
|
+
* without indenting what follows, and `clear` clears nothing.
|
|
11
|
+
*/
|
|
12
|
+
const stderrConsole = (console, clock) => {
|
|
13
|
+
const counts = /* @__PURE__ */ new Map();
|
|
14
|
+
const timers = /* @__PURE__ */ new Map();
|
|
15
|
+
const write = (...args) => console.error(...args);
|
|
16
|
+
const elapsed = (label, data) => {
|
|
17
|
+
const start = timers.get(label);
|
|
18
|
+
if (start !== void 0) {
|
|
19
|
+
const millis = Number(clock.monotonicTimeNanosUnsafe() - start) / 1e6;
|
|
20
|
+
write("%s: %sms", label, Number(millis.toFixed(3)), ...data);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const labeled = (label, args) => {
|
|
24
|
+
const [first, ...rest] = args;
|
|
25
|
+
write(...Predicate.isString(first) ? [`${label}: ${first}`, ...rest] : [label, ...args]);
|
|
26
|
+
};
|
|
27
|
+
const group = (...label) => {
|
|
28
|
+
if (label.some((part) => part !== void 0)) write(...label);
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
assert: (condition, ...args) => {
|
|
32
|
+
if (!condition) labeled("Assertion failed", args);
|
|
33
|
+
},
|
|
34
|
+
clear: Function.constVoid,
|
|
35
|
+
count: (label = "default") => {
|
|
36
|
+
const count = (counts.get(label) ?? 0) + 1;
|
|
37
|
+
counts.set(label, count);
|
|
38
|
+
write("%s: %d", label, count);
|
|
39
|
+
},
|
|
40
|
+
countReset: (label = "default") => {
|
|
41
|
+
counts.delete(label);
|
|
42
|
+
},
|
|
43
|
+
debug: write,
|
|
44
|
+
dir: (item) => write("%O", item),
|
|
45
|
+
dirxml: write,
|
|
46
|
+
error: write,
|
|
47
|
+
group,
|
|
48
|
+
groupCollapsed: group,
|
|
49
|
+
groupEnd: Function.constVoid,
|
|
50
|
+
info: write,
|
|
51
|
+
log: write,
|
|
52
|
+
table: (data) => write(data),
|
|
53
|
+
time: (label = "default") => {
|
|
54
|
+
timers.set(label, clock.monotonicTimeNanosUnsafe());
|
|
55
|
+
},
|
|
56
|
+
timeEnd: (label = "default") => {
|
|
57
|
+
elapsed(label, []);
|
|
58
|
+
timers.delete(label);
|
|
59
|
+
},
|
|
60
|
+
timeLog: (label = "default", ...data) => elapsed(label, data),
|
|
61
|
+
trace: (...args) => {
|
|
62
|
+
const { stack = "" } = /* @__PURE__ */ new Error();
|
|
63
|
+
const frames = stack.split("\n").slice(2).join("\n");
|
|
64
|
+
labeled("Trace", args);
|
|
65
|
+
if (frames !== "") write(frames);
|
|
66
|
+
},
|
|
67
|
+
warn: write
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* `effect` writing its console output and Effect logs to stderr alone, through the console
|
|
72
|
+
* it runs with: for what a surface runs where stdout carries data, a stdio server's program
|
|
73
|
+
* or a CLI command's builder, authorizer and handler.
|
|
74
|
+
*/
|
|
75
|
+
const logToStderr = (effect) => Effect.provideServiceEffect(effect, Console.Console, Effect.zipWith(Effect.service(Console.Console), Effect.service(Clock.Clock), stderrConsole));
|
|
76
|
+
//#endregion
|
|
77
|
+
export { logToStderr };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Action_d_exports, Errors } from "../Action.mjs";
|
|
2
|
+
import { HttpServerResponse } from "effect/http";
|
|
3
|
+
//#region src/internal/declared.d.ts
|
|
4
|
+
/** The list an `error` option of type `G` stands for: one schema is a list of one. */
|
|
5
|
+
type ListOf<G> = G extends Errors ? G : readonly [G];
|
|
6
|
+
/**
|
|
7
|
+
* The errors options `O` declare, as at run time: those `error` always gives, or, where it may
|
|
8
|
+
* be absent, those it gives or none, so clients decode each that may arrive.
|
|
9
|
+
*/
|
|
10
|
+
export type ErrorsOf<O> = O extends unknown ? "error" extends keyof O ? O extends {
|
|
11
|
+
readonly error: infer G extends Errors | Errors[number];
|
|
12
|
+
} ? ListOf<G> : ListOf<Extract<O["error" & keyof O], Errors | Errors[number]>> | [] : [] : never;
|
|
13
|
+
//#endregion
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { unsuspended } from "./actions.mjs";
|
|
2
|
+
import { Schema, SchemaAST } from "effect";
|
|
3
|
+
import { HttpServerResponse } from "effect/http";
|
|
4
|
+
import { HttpApiSchema } from "effect/http-api";
|
|
5
|
+
//#region src/internal/declared.ts
|
|
6
|
+
/** The status an error schema states, as `HttpApi` reads it. */
|
|
7
|
+
const statusOf = SchemaAST.resolveAt("httpApiStatus");
|
|
8
|
+
/** The status a schema states, or the first one a suspension it wraps states. */
|
|
9
|
+
const statusThrough = (ast) => statusOf(ast) ?? (SchemaAST.isSuspend(ast) ? statusThrough(ast.thunk()) : void 0);
|
|
10
|
+
/**
|
|
11
|
+
* The schemas an endpoint declares for one error, each with the status it is sent with. `HttpApi`
|
|
12
|
+
* reads a status off each declared schema, never off a union's members nor through a suspension, so
|
|
13
|
+
* a plain union without a status of its own declares each member, and a suspended error, as a
|
|
14
|
+
* recursive one is written, without a status of its own states the status of what it suspends. An
|
|
15
|
+
* error without a status is an outcome the action expects, not a server fault: it is sent as 422,
|
|
16
|
+
* rather than `HttpApi`'s 500, which clients and proxies read as the server failing.
|
|
17
|
+
*/
|
|
18
|
+
const declared = (error) => {
|
|
19
|
+
const status = statusThrough(error.ast);
|
|
20
|
+
if (status !== void 0) return [statusOf(error.ast) === void 0 ? HttpApiSchema.status(status)(error) : error];
|
|
21
|
+
const resolved = unsuspended(error.ast);
|
|
22
|
+
if (SchemaAST.isUnion(resolved) && resolved.checks === void 0 && resolved.encoding === void 0) return resolved.types.flatMap((member) => declared(Schema.make(member)));
|
|
23
|
+
return [HttpApiSchema.status(422)(error)];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* `error` as an endpoint declaring `errors` sends it: its JSON, encoded by the first declared
|
|
27
|
+
* schema that accepts it, with that schema's status. None for a value no schema accepts.
|
|
28
|
+
*/
|
|
29
|
+
const declaredResponse = (errors, error) => {
|
|
30
|
+
const schema = errors.flatMap(declared).find((each) => Schema.is(each)(error));
|
|
31
|
+
return schema === void 0 ? void 0 : HttpServerResponse.jsonUnsafe(Schema.encodeUnknownSync(Schema.toCodecJson(schema))(error), { status: statusOf(schema.ast) ?? 422 });
|
|
32
|
+
};
|
|
33
|
+
//#endregion
|
|
34
|
+
export { declared, declaredResponse };
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
//#region src/internal/errors.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* One thing wrong with an input: where, as the keys and indexes leading to it from the input's
|
|
5
|
+
* root, none for the input itself, and what the schema expects there.
|
|
6
|
+
*/
|
|
7
|
+
declare const Issue: Schema.Struct<{
|
|
8
|
+
readonly path: Schema.$Array<Schema.Union<readonly [Schema.String, Schema.Finite]>>;
|
|
9
|
+
readonly message: Schema.String;
|
|
10
|
+
}>;
|
|
11
|
+
/** One thing wrong with an input, as `InvalidInput.issues` lists it. */
|
|
12
|
+
type Issue = (typeof Issue)["Type"];
|
|
13
|
+
declare const InvalidInput_base: Schema.Class<InvalidInput, Schema.TaggedStruct<"InvalidInput", {
|
|
14
|
+
readonly message: Schema.withConstructorDefault<Schema.String>;
|
|
15
|
+
readonly issues: Schema.withConstructorDefault<Schema.$Array<Schema.Struct<{
|
|
16
|
+
readonly path: Schema.$Array<Schema.Union<readonly [Schema.String, Schema.Finite]>>;
|
|
17
|
+
readonly message: Schema.String;
|
|
18
|
+
}>>>;
|
|
19
|
+
}>, import("effect/Cause").YieldableError>;
|
|
20
|
+
/**
|
|
21
|
+
* The request's input does not decode against the action's input. HTTP answers every such
|
|
22
|
+
* request with it, its message the schema's own description of what is wrong, and `issues`
|
|
23
|
+
* the same description an issue at a time, for a caller that points at a field. A handler
|
|
24
|
+
* failing with it for input that decodes but cannot be served may name its own.
|
|
25
|
+
*/
|
|
26
|
+
export declare class InvalidInput extends InvalidInput_base {
|
|
27
|
+
private readonly "~builtIn";
|
|
28
|
+
/**
|
|
29
|
+
* The `InvalidInput` for input that did not decode, as every surface answers it: the
|
|
30
|
+
* schema's message, and each of its issues by path, for code that decodes input of its own,
|
|
31
|
+
* such as a header or a route parameter. Neither carries a value of the input: decoding
|
|
32
|
+
* runs without `reportInput`, so no issue holds one to format. A symbol key is sent as its
|
|
33
|
+
* string form, `Symbol(name)`, as JSON has no symbols, which tells it from a string key of
|
|
34
|
+
* that name.
|
|
35
|
+
*/
|
|
36
|
+
static readonly fromSchemaError: ({ issue, message }: Schema.SchemaError) => InvalidInput;
|
|
37
|
+
}
|
|
38
|
+
declare const Unauthenticated_base: Schema.Class<Unauthenticated, Schema.TaggedStruct<"Unauthenticated", {
|
|
39
|
+
readonly message: Schema.withConstructorDefault<Schema.String>;
|
|
40
|
+
}>, import("effect/Cause").YieldableError>;
|
|
41
|
+
/** The caller is not authenticated: authentication or an implementation's `authorize` refuses. */
|
|
42
|
+
export declare class Unauthenticated extends Unauthenticated_base {
|
|
43
|
+
private readonly "~builtIn";
|
|
44
|
+
}
|
|
45
|
+
declare const Forbidden_base: Schema.Class<Forbidden, Schema.TaggedStruct<"Forbidden", {
|
|
46
|
+
readonly message: Schema.withConstructorDefault<Schema.String>;
|
|
47
|
+
readonly scopes: Schema.optionalKey<Schema.NonEmptyArray<Schema.String>>;
|
|
48
|
+
}>, import("effect/Cause").YieldableError>;
|
|
49
|
+
/**
|
|
50
|
+
* The caller may not run this action: an implementation's `authorize` refuses. `scopes`,
|
|
51
|
+
* when given, are every OAuth scope the call needs: a refusal naming them is answered with
|
|
52
|
+
* the `insufficient_scope` challenge an OAuth client re-authorizes on.
|
|
53
|
+
*/
|
|
54
|
+
export declare class Forbidden extends Forbidden_base {
|
|
55
|
+
private readonly "~builtIn";
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* What authentication or an implementation's `authorize` refuses a caller with, instead of
|
|
59
|
+
* running a handler. An error an action declares, such as a rate limit, is not a refusal. As a
|
|
60
|
+
* schema, it tells a refusal apart: `Schema.is(Action.Refusal)(error)`.
|
|
61
|
+
*/
|
|
62
|
+
export declare const Refusal: Schema.Union<readonly [typeof Unauthenticated, typeof Forbidden]>;
|
|
63
|
+
/** A refusal: `Unauthenticated | Forbidden`. */
|
|
64
|
+
export type Refusal = (typeof Refusal)["Type"];
|
|
65
|
+
/**
|
|
66
|
+
* The failures every endpoint and tool declares beyond its action's own, and any handler may
|
|
67
|
+
* fail with: bad input, and every refusal. No action or binding declares them itself.
|
|
68
|
+
*/
|
|
69
|
+
export declare const builtIns: readonly [typeof InvalidInput, typeof Unauthenticated, typeof Forbidden];
|
|
70
|
+
/** The built-in failures' schemas. */
|
|
71
|
+
export type BuiltIns = (typeof builtIns)[number];
|
|
72
|
+
/** The built-in failures as one schema: `Schema.is(Action.BuiltIn)(error)`. */
|
|
73
|
+
export declare const BuiltIn: Schema.Union<readonly [typeof InvalidInput, typeof Unauthenticated, typeof Forbidden]>;
|
|
74
|
+
/** A built-in failure: `InvalidInput | Refusal`. */
|
|
75
|
+
export type BuiltIn = (typeof BuiltIn)["Type"];
|
|
76
|
+
//#endregion
|
|
77
|
+
export { Issue };
|