@distilled.cloud/fly-io 1.0.0-rc.11 → 1.0.0-rc.13
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 +34 -0
- package/lib/protocol.d.ts +2 -2
- package/lib/protocol.d.ts.map +1 -1
- package/lib/protocol.js +39 -24
- package/lib/protocol.js.map +1 -1
- package/lib/protocol.test.d.ts +2 -0
- package/lib/protocol.test.d.ts.map +1 -0
- package/lib/protocol.test.js +125 -0
- package/lib/protocol.test.js.map +1 -0
- package/lib/response-validation.test.d.ts +4 -0
- package/lib/response-validation.test.d.ts.map +1 -0
- package/lib/response-validation.test.js +32 -0
- package/lib/response-validation.test.js.map +1 -0
- package/lib/services/machines.d.ts +206 -58
- package/lib/services/machines.d.ts.map +1 -1
- package/lib/services/machines.js +200 -50
- package/lib/services/machines.js.map +1 -1
- package/lib/sprites-mint.test.d.ts +2 -0
- package/lib/sprites-mint.test.d.ts.map +1 -0
- package/lib/sprites-mint.test.js +170 -0
- package/lib/sprites-mint.test.js.map +1 -0
- package/package.json +4 -4
- package/src/protocol.test.ts +234 -0
- package/src/protocol.ts +81 -42
- package/src/response-validation.test.ts +46 -0
- package/src/services/machines.ts +479 -117
- package/src/sprites-mint.test.ts +270 -0
package/src/protocol.ts
CHANGED
|
@@ -19,16 +19,18 @@
|
|
|
19
19
|
* `Authorization: Bearer` on sprite ops. There is no `SPRITES_TOKEN`.
|
|
20
20
|
*/
|
|
21
21
|
import * as Context from "effect/Context";
|
|
22
|
+
import * as Deferred from "effect/Deferred";
|
|
22
23
|
import * as Effect from "effect/Effect";
|
|
24
|
+
import * as Exit from "effect/Exit";
|
|
23
25
|
import * as Layer from "effect/Layer";
|
|
24
26
|
import * as Option from "effect/Option";
|
|
25
27
|
import * as Redacted from "effect/Redacted";
|
|
26
28
|
import * as Schema from "effect/Schema";
|
|
27
29
|
import type * as AST from "effect/SchemaAST";
|
|
28
|
-
import * as HttpClient from "effect/
|
|
29
|
-
import type * as HttpClientError from "effect/
|
|
30
|
-
import * as HttpClientRequest from "effect/
|
|
31
|
-
import type * as HttpClientResponse from "effect/
|
|
30
|
+
import * as HttpClient from "effect/http/HttpClient";
|
|
31
|
+
import type * as HttpClientError from "effect/http/HttpClientError";
|
|
32
|
+
import * as HttpClientRequest from "effect/http/HttpClientRequest";
|
|
33
|
+
import type * as HttpClientResponse from "effect/http/HttpClientResponse";
|
|
32
34
|
import * as API from "@distilled.cloud/core/api";
|
|
33
35
|
import {
|
|
34
36
|
makeRestProtocol,
|
|
@@ -46,6 +48,10 @@ import {
|
|
|
46
48
|
InternalServerError,
|
|
47
49
|
type ConfigError,
|
|
48
50
|
} from "@distilled.cloud/core/errors";
|
|
51
|
+
import {
|
|
52
|
+
failIfStrict,
|
|
53
|
+
validateResponse,
|
|
54
|
+
} from "@distilled.cloud/core/response-validation";
|
|
49
55
|
import { parseRetryAfterForStatus } from "@distilled.cloud/core/retry-after";
|
|
50
56
|
import {
|
|
51
57
|
Credentials,
|
|
@@ -147,6 +153,7 @@ const FlyIoProtocolRest: Layer.Layer<API.Protocol> = makeRestProtocol<Config>({
|
|
|
147
153
|
}),
|
|
148
154
|
errorEnvelope: flyErrorEnvelope,
|
|
149
155
|
unknownError: ({ message, body }) => new UnknownFlyIoError({ message, body }),
|
|
156
|
+
parseError: ({ body, cause }) => new FlyIoParseError({ body, cause }),
|
|
150
157
|
});
|
|
151
158
|
|
|
152
159
|
export const FlyIoProtocol: Layer.Layer<API.Protocol> = Layer.effect(
|
|
@@ -180,6 +187,7 @@ export const FlyApiProtocol: Layer.Layer<API.Protocol> =
|
|
|
180
187
|
errorEnvelope: flyErrorEnvelope,
|
|
181
188
|
unknownError: ({ message, body }) =>
|
|
182
189
|
new UnknownFlyIoError({ message, body }),
|
|
190
|
+
parseError: ({ body, cause }) => new FlyIoParseError({ body, cause }),
|
|
183
191
|
});
|
|
184
192
|
|
|
185
193
|
const parseMaybeNdjson = (body: unknown): unknown => {
|
|
@@ -432,34 +440,42 @@ const mintOnce = (fly: Config) =>
|
|
|
432
440
|
}),
|
|
433
441
|
);
|
|
434
442
|
}
|
|
435
|
-
// Cache the raw string
|
|
436
|
-
// registry entry is not restored from a memoized Exit).
|
|
443
|
+
// Cache the raw string, not a Redacted whose registry entry may be erased.
|
|
437
444
|
return token;
|
|
438
445
|
});
|
|
439
446
|
|
|
440
447
|
const mintedTokens = new Map<string, string>();
|
|
441
|
-
const mintInFlight = new Map<
|
|
448
|
+
const mintInFlight = new Map<
|
|
449
|
+
string,
|
|
450
|
+
Deferred.Deferred<string, Effect.Error<ReturnType<typeof mintOnce>>>
|
|
451
|
+
>();
|
|
442
452
|
|
|
443
453
|
/** Mint (and process-wide cache) a Sprites bearer from `FLY_API_TOKEN`. */
|
|
444
454
|
const mintSpritesToken = (fly: Config) =>
|
|
445
|
-
Effect.
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
)
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
455
|
+
Effect.uninterruptibleMask((restore) =>
|
|
456
|
+
Effect.suspend(() => {
|
|
457
|
+
const key = Redacted.value(fly.apiKey);
|
|
458
|
+
const hit = mintedTokens.get(key);
|
|
459
|
+
if (hit !== undefined) return Effect.succeed(hit);
|
|
460
|
+
const pending = mintInFlight.get(key);
|
|
461
|
+
if (pending !== undefined) return restore(Deferred.await(pending));
|
|
462
|
+
const result = Deferred.makeUnsafe<
|
|
463
|
+
string,
|
|
464
|
+
Effect.Error<ReturnType<typeof mintOnce>>
|
|
465
|
+
>();
|
|
466
|
+
mintInFlight.set(key, result);
|
|
467
|
+
return restore(mintOnce(fly)).pipe(
|
|
468
|
+
Effect.onExit((exit) =>
|
|
469
|
+
Effect.gen(function* () {
|
|
470
|
+
if (Exit.isSuccess(exit)) mintedTokens.set(key, exit.value);
|
|
471
|
+
// Release all waiters, including on defects and owner interruption.
|
|
472
|
+
mintInFlight.delete(key);
|
|
473
|
+
yield* Deferred.done(result, exit);
|
|
474
|
+
}),
|
|
475
|
+
),
|
|
476
|
+
);
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
463
479
|
|
|
464
480
|
const encodeSpritesRequest = ({
|
|
465
481
|
input,
|
|
@@ -527,12 +543,17 @@ const decodeSpritesResponse = ({
|
|
|
527
543
|
) {
|
|
528
544
|
return yield* fail(new SpritesNotEnabled({ message }));
|
|
529
545
|
}
|
|
530
|
-
const typed = matchTypedError(
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
546
|
+
const typed = matchTypedError(
|
|
547
|
+
errorClasses,
|
|
548
|
+
status,
|
|
549
|
+
[
|
|
550
|
+
{
|
|
551
|
+
code: typeof env.code === "number" ? env.code : undefined,
|
|
552
|
+
message,
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
{ body: nonJson ? text : json, headers },
|
|
556
|
+
);
|
|
536
557
|
if (typed !== undefined) return yield* fail(typed);
|
|
537
558
|
|
|
538
559
|
const StatusErrorClass = (HTTP_STATUS_MAP as Record<number, unknown>)[
|
|
@@ -567,13 +588,20 @@ const decodeSpritesResponse = ({
|
|
|
567
588
|
);
|
|
568
589
|
}
|
|
569
590
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
591
|
+
// Strict mode checks the mapped body (or parsed exec frames) against the
|
|
592
|
+
// output schema.
|
|
593
|
+
const body: unknown =
|
|
594
|
+
execFrames ?? parseMaybeNdjson(nonJson ? text : (json ?? {}));
|
|
595
|
+
const mapped = yield* validateResponse(
|
|
596
|
+
outputAst,
|
|
597
|
+
mapKeys(outputAst, body, "decode"),
|
|
598
|
+
(cause) =>
|
|
599
|
+
new FlyIoParseError({
|
|
600
|
+
body: execFrames ?? (nonJson ? text : json),
|
|
601
|
+
cause,
|
|
602
|
+
}),
|
|
603
|
+
).pipe(Effect.catch(fail));
|
|
604
|
+
return wrapSensitive(outputAst, mapped);
|
|
577
605
|
});
|
|
578
606
|
|
|
579
607
|
/** Sprites REST at `https://api.sprites.dev/v1`, minted from `FLY_API_TOKEN`. */
|
|
@@ -619,7 +647,10 @@ const matchGraphqlError = (
|
|
|
619
647
|
if (envelope._tag === "Some" && envelope.value.errors?.length) {
|
|
620
648
|
const first = envelope.value.errors[0]!;
|
|
621
649
|
const message = first.message;
|
|
622
|
-
const typed = matchTypedError(errorClasses, status, [{ message }]
|
|
650
|
+
const typed = matchTypedError(errorClasses, status, [{ message }], {
|
|
651
|
+
body: errorBody,
|
|
652
|
+
headers,
|
|
653
|
+
});
|
|
623
654
|
if (typed !== undefined) return fail(typed);
|
|
624
655
|
if (
|
|
625
656
|
/not authorized to access this createextensiontosagreement/i.test(message)
|
|
@@ -730,12 +761,14 @@ const graphqlDecode = ({
|
|
|
730
761
|
if (status >= 400) {
|
|
731
762
|
return yield* matchGraphqlError(status, text, headers, errors);
|
|
732
763
|
}
|
|
733
|
-
|
|
764
|
+
// Lenient mode returns the body as read.
|
|
765
|
+
return yield* failIfStrict(
|
|
734
766
|
new FlyIoParseError({
|
|
735
767
|
body: text,
|
|
736
768
|
cause: "response body is not valid JSON",
|
|
737
769
|
}),
|
|
738
|
-
|
|
770
|
+
text,
|
|
771
|
+
).pipe(Effect.catch(fail));
|
|
739
772
|
}
|
|
740
773
|
|
|
741
774
|
const envelope = json as { data?: unknown; errors?: unknown[] } | null;
|
|
@@ -761,7 +794,13 @@ const graphqlDecode = ({
|
|
|
761
794
|
: undefined;
|
|
762
795
|
}
|
|
763
796
|
}
|
|
764
|
-
|
|
797
|
+
// Strict mode checks the payload at the response path against the
|
|
798
|
+
// output schema.
|
|
799
|
+
return yield* validateResponse(
|
|
800
|
+
outputAst,
|
|
801
|
+
payload === undefined ? null : payload,
|
|
802
|
+
(cause) => new FlyIoParseError({ body: json, cause }),
|
|
803
|
+
).pipe(Effect.catch(fail));
|
|
765
804
|
});
|
|
766
805
|
|
|
767
806
|
export const FlyGraphqlProtocol: Layer.Layer<API.Protocol> = Layer.succeed(
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { runValidationModes } from "@distilled.cloud/core/testing";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import { credentials } from "./credentials.ts";
|
|
5
|
+
import { FlyIoParseError } from "./errors.ts";
|
|
6
|
+
import * as Retry from "./retry.ts";
|
|
7
|
+
import { listApps } from "./services/machines.ts";
|
|
8
|
+
import type { FlyIoOpError } from "./protocol.ts";
|
|
9
|
+
|
|
10
|
+
// listApps (Machines REST) declares `{ apps?: App[]; total_apps?: number }`.
|
|
11
|
+
const run = (body: string) =>
|
|
12
|
+
runValidationModes(
|
|
13
|
+
listApps({ org_slug: "personal" }).pipe(
|
|
14
|
+
Retry.none,
|
|
15
|
+
Effect.provide(credentials({ apiKey: "test" })),
|
|
16
|
+
),
|
|
17
|
+
{ body },
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
describe("Fly.io Machines response validation", () => {
|
|
21
|
+
test("a matching body succeeds unchanged in both modes", async () => {
|
|
22
|
+
const body = { apps: [{ id: "app-1", name: "my-app" }], total_apps: 1 };
|
|
23
|
+
const { lenient, strict } = await run(JSON.stringify(body));
|
|
24
|
+
expect(lenient).toMatchObject({ _tag: "Success", success: body });
|
|
25
|
+
expect(strict).toMatchObject({ _tag: "Success", success: body });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a member with the wrong type: lenient returns it, strict fails", async () => {
|
|
29
|
+
const body = { apps: [], total_apps: "one" };
|
|
30
|
+
const { lenient, strict } = await run(JSON.stringify(body));
|
|
31
|
+
expect(lenient).toMatchObject({ _tag: "Success", success: body });
|
|
32
|
+
expect(strict._tag).toBe("Failure");
|
|
33
|
+
expect((strict as any).failure).toBeInstanceOf(FlyIoParseError);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a non-JSON body: lenient returns the text, strict fails", async () => {
|
|
37
|
+
const { lenient, strict } = await run("not json");
|
|
38
|
+
expect(lenient).toMatchObject({ _tag: "Success", success: "not json" });
|
|
39
|
+
expect((strict as any).failure).toBeInstanceOf(FlyIoParseError);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// FlyIoParseError is part of every operation's declared error type.
|
|
44
|
+
export const parseErrorIsDeclared: [FlyIoParseError] extends [FlyIoOpError]
|
|
45
|
+
? true
|
|
46
|
+
: false = true;
|