feat(sdk): Effect v4 + @effect/openapi-generator; typed pf.api & example ladder
Drop Orval for the first-party @effect/openapi-generator (OpenAPI 3.1 -> Effect Schema + a typed HttpClient client) and bump effect 3.19 -> 4.0.0-beta.98. Port the hand-written surfaces to the v4 API (Result over Either, Context.Service, Codec, Literals/Union arrays, Stream/Schedule/ Effect renames). Transport (CA-pinning fetch) and the reconnecting SSE source are kept intact. Make the SDK approachable for non-Effect users: - Add pf.api.* on the Promise facade: the generated client surfaced as typed, Promise-native methods (await pf.api.listPairedClients()), so REST calls are autocompleted and checked instead of stringly-typed pf.request(method, path, body) + `as` casts. Zero-drift veneer over make(httpClient), backed by the same pinning fetch. pf.request stays as the untyped escape hatch. - Re-tier examples into a 1-4 complexity ladder, rewritten onto pf.api.* (the typed payloads caught a wrong `launch` shape in provider-sync); the Effect example is labelled advanced. Add examples/ to tsconfig so they are typechecked (stops rot). typecheck + 19 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
// The typed management API for the Promise facade (RFC §7): every REST endpoint of the host as
|
||||
// an autocompletable method with checked request/response types — named exactly as in the
|
||||
// OpenAPI document (`listPairedClients`, `stopSession`, `reconcileProviderEntries`, …). This is
|
||||
// the typed front door; `pf.request(method, path, body)` stays as the untyped escape hatch.
|
||||
//
|
||||
// It's a thin, zero-drift veneer over the generated client (`./gen/punktfunk.ts`, from
|
||||
// `@effect/openapi-generator`): the generated methods return Effects, so each is run to a Promise
|
||||
// here. The transport is the SAME CA-pinning fetch the rest of the SDK uses (config.ts), fed to
|
||||
// Effect's `HttpClient` via the overridable `FetchHttpClient.Fetch` reference — so the loopback
|
||||
// pin is preserved and there is still exactly one place that knows how to reach the host.
|
||||
import { Effect } from "effect";
|
||||
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
||||
import * as HttpClient from "effect/unstable/http/HttpClient";
|
||||
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
|
||||
import type { ResolvedConfig } from "./config.js";
|
||||
import * as gen from "./gen/punktfunk.js";
|
||||
|
||||
/**
|
||||
* Make a trailing argument that merely *accepts* `undefined` genuinely optional. The generated
|
||||
* methods type their options bag as `{…} | undefined` (a required param that tolerates `undefined`),
|
||||
* so without this you'd have to write `listPairedClients(undefined)`. Endpoints with a required
|
||||
* body (`{ payload }`, not `undefined`-able) are left untouched.
|
||||
*/
|
||||
type OptionalizeTail<A extends readonly unknown[]> = A extends readonly [
|
||||
...infer Init,
|
||||
infer Last,
|
||||
]
|
||||
? undefined extends Last
|
||||
? [...Init, Last?]
|
||||
: A
|
||||
: A;
|
||||
|
||||
/** Each generated method returns an Effect; expose it as a Promise of the decoded success value. */
|
||||
type Promiseify<T> = T extends (
|
||||
...args: infer A
|
||||
) => Effect.Effect<infer Success, infer _E, infer _R>
|
||||
? (...args: OptionalizeTail<A>) => Promise<Success>
|
||||
: never;
|
||||
|
||||
/** The event stream (an Effect `Stream`) and the void catch-all — served by `pf.events` instead. */
|
||||
type NonApiMethods = "httpClient" | "streamEvents" | "streamEventsSse";
|
||||
|
||||
/**
|
||||
* The host's management API, fully typed: `await pf.api.listPairedClients()` gives a typed array,
|
||||
* `await pf.api.reconcileProviderEntries("romm", { payload })` checks the body at compile time —
|
||||
* no hand-written paths, no `unknown` casts. A failing call rejects with the endpoint's typed
|
||||
* error. The live-event SSE endpoint is intentionally absent — subscribe via `pf.events`.
|
||||
*/
|
||||
export type HostApi = {
|
||||
readonly [K in Exclude<keyof gen.Punktfunk, NonApiMethods>]: Promiseify<
|
||||
gen.Punktfunk[K]
|
||||
>;
|
||||
};
|
||||
|
||||
/** Build the pinning `HttpClient`: base URL + bearer + the config's CA-pinning fetch. */
|
||||
const httpClientFor = (cfg: ResolvedConfig): Promise<HttpClient.HttpClient> =>
|
||||
Effect.runPromise(
|
||||
HttpClient.HttpClient.pipe(
|
||||
Effect.map((client) =>
|
||||
HttpClient.mapRequest((request: HttpClientRequest.HttpClientRequest) =>
|
||||
request.pipe(
|
||||
HttpClientRequest.prependUrl(cfg.url),
|
||||
HttpClientRequest.bearerToken(cfg.token),
|
||||
HttpClientRequest.acceptJson,
|
||||
),
|
||||
)(client),
|
||||
),
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
// Override the default `globalThis.fetch` with the CA-pinning one (config.ts).
|
||||
Effect.provideService(FetchHttpClient.Fetch, cfg.fetch),
|
||||
),
|
||||
);
|
||||
|
||||
/** Skipped at runtime too (not just in the type): these aren't Promise-shaped API calls. */
|
||||
const NON_API: ReadonlySet<string> = new Set<NonApiMethods>([
|
||||
"httpClient",
|
||||
"streamEvents",
|
||||
"streamEventsSse",
|
||||
]);
|
||||
|
||||
/** The typed API surface over a resolved connection — each method runs its Effect to a Promise. */
|
||||
export const makeHostApi = async (cfg: ResolvedConfig): Promise<HostApi> => {
|
||||
const client = gen.make(await httpClientFor(cfg)) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const api: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(client)) {
|
||||
if (NON_API.has(key)) continue;
|
||||
const method = client[key];
|
||||
if (typeof method !== "function") continue;
|
||||
api[key] = (...args: unknown[]) =>
|
||||
Effect.runPromise(
|
||||
(method as (...a: unknown[]) => Effect.Effect<unknown>)(...args),
|
||||
);
|
||||
}
|
||||
return api as HostApi;
|
||||
};
|
||||
Reference in New Issue
Block a user