From 1f9599e4907eb366286d7010a3be9be9cb4a3832 Mon Sep 17 00:00:00 2001 From: aabedraba Date: Wed, 16 Sep 2026 19:01:10 +0200 Subject: [PATCH] feat: Losen dependency between @unikraft/cloud and plugins Signed-off-by: aabedraba --- js/tools/tsplugingen/Makefile | 54 +++------- js/tools/tsplugingen/README.md | 102 ++++++------------ js/tools/tsplugingen/publish-check.sh | 2 +- js/tools/tsplugingen/sdk-range.sh | 67 ------------ js/tools/tsplugingen/static/tsconfig.json | 2 +- js/tools/tsplugingen/templates/README.md.tmpl | 21 ++-- js/tools/tsplugingen/templates/index.ts.tmpl | 31 ++++-- .../tsplugingen/templates/package.json.tmpl | 4 - js/tools/tsplugingen/templates/resources.tmpl | 31 +++--- .../tsplugingen/templates/transport.ts.tmpl | 55 ++++++++++ 10 files changed, 154 insertions(+), 215 deletions(-) delete mode 100755 js/tools/tsplugingen/sdk-range.sh create mode 100644 js/tools/tsplugingen/templates/transport.ts.tmpl diff --git a/js/tools/tsplugingen/Makefile b/js/tools/tsplugingen/Makefile index e980569..1406d6e 100644 --- a/js/tools/tsplugingen/Makefile +++ b/js/tools/tsplugingen/Makefile @@ -17,12 +17,12 @@ NPM ?= npm NPX ?= npx NODE ?= node -# sdk-range.sh and publish-check.sh run npm and node too. +# publish-check.sh runs npm and node too. export NPM export NODE -# One knob for four things: the OpenAPI branch, the generator branch, the -# dist-tag of the SDK to build against, and the dist-tag to publish under. +# One knob for three things: the OpenAPI branch, the generator branch, and the +# dist-tag to publish under. CHANNEL ?= prod-staging DIST_TAG = $(if $(filter prod-stable,$(CHANNEL)),latest,next) @@ -32,27 +32,6 @@ OPENAPI_GEN_MOD = unikraft.com/x/tools/openapi-gen OPENAPI_GEN_REVISION = $(eval OPENAPI_GEN_REVISION := $(shell $(GO) list -m -f '{{.Version}}' $(OPENAPI_GEN_MOD)@$(CHANNEL) 2>/dev/null))$(OPENAPI_GEN_REVISION) OPENAPI_GEN ?= $(GO) run $(OPENAPI_GEN_MOD)@$(OPENAPI_GEN_REVISION) -# A leaf subpath, so the plugin package does not pull the SDK's idiomatic layer -# into its module graph. Needs @unikraft/cloud >=0.1.1, which exports it. -CLIENT_IMPORT ?= @unikraft/cloud/core/http - -SDK_SPEC ?= @unikraft/cloud@$(DIST_TAG) - -# `build` installs the SDK from $(BUILD), two directories below this one, so -# npm would look up a relative local spec from the wrong directory there. Only -# path- and tarball-shaped specs become absolute; a registry spec such as -# "@unikraft/cloud@next" has to reach npm unchanged. -SDK_SPEC_ABS = $(if $(filter /% ./% ../% %.tgz,$(SDK_SPEC)),$(abspath $(SDK_SPEC)),$(SDK_SPEC)) - -# Derives the range: "0.1.1-next.N" becomes ">=0.1.1-0 <0.2.0". -# sdk-range.sh explains why. -# -# The $(eval) memoizes the value, like OPENAPI_GEN_REVISION above. Each -# expansion would otherwise run the script again, and each run can ask the npm -# registry. One lookup also guarantees that package.json and configHash carry -# the same range when the registry moves mid-build. -SDK_RANGE ?= $(eval SDK_RANGE := $(shell ./sdk-range.sh $(SDK_SPEC_ABS)))$(SDK_RANGE) - PUBLISH_FLAGS ?= # Every plugin under SPEC_ROOT that has a compiled spec. @@ -70,13 +49,11 @@ VERSION = $(shell awk '/^info:/{f=1;next} /^[^[:space:]#]/{f=0} f&&/^[[:space:]] SPEC_HASH = $(shell $(SHA256) "$(SPEC)" 2>/dev/null | cut -c1-64) TEMPLATES_HASH = $(shell $(SHA256) $(sort $(wildcard $(TEMPLATES)/* $(STATIC)/*)) 2>/dev/null | $(SHA256) | cut -c1-64) -# The spec and the templates are not the only inputs. The peer range, the -# client import and the generator decide the output as well, and none of them -# leaves a mark in any file the two hashes above cover. Without this, staging -# opening a new patch (0.1.1-next.N -> 0.1.2-next.0) rewrites every peer range -# while publish still reports the published version as built from these -# sources. -CONFIG_HASH = $(shell printf '%s\n%s\n%s\n' "$(SDK_RANGE)" "$(CLIENT_IMPORT)" "$(OPENAPI_GEN)" | $(SHA256) | cut -c1-64) +# The spec and the templates are not the only inputs. The generator decides +# the output as well, and it leaves no mark in any file the two hashes above +# cover. Without this, a new generator revision ships in no package: publish +# still reports the published version as built from these sources. +CONFIG_HASH = $(shell printf '%s\n' "$(OPENAPI_GEN)" | $(SHA256) | cut -c1-64) .PHONY: all list clean generate build publish publish-all config check-plugins @@ -105,9 +82,6 @@ config: @echo "dist-tag: $(DIST_TAG)" @echo "spec root: $(SPEC_ROOT)" @echo "plugins: $(PLUGINS)" - @echo "client import: $(CLIENT_IMPORT)" - @echo "sdk spec: $(SDK_SPEC_ABS)" - @echo "sdk range: $(SDK_RANGE)" @echo "generator: $(OPENAPI_GEN)" @echo "templates: $(TEMPLATES_HASH)" @echo "config: $(CONFIG_HASH)" @@ -122,7 +96,6 @@ generate: @test -f "$(SPEC)" || { echo "error: no spec at $(SPEC) (compile it with 'npx tsp compile $(PLUGIN)/api.tsp')"; exit 1; } @test -n "$(VERSION)" || { echo "error: no semver info.version in $(SPEC)"; exit 1; } @$(if $(filter file,$(origin OPENAPI_GEN)),test -n "$(OPENAPI_GEN_REVISION)" || { echo "error: could not resolve $(OPENAPI_GEN_MOD)@$(CHANNEL) to a revision with '$(GO) list -m'"; exit 1; }) - @test -n "$(SDK_RANGE)" || { echo "error: could not resolve a version from SDK_SPEC=$(SDK_SPEC), so the @unikraft/cloud peer range is unknown; set SDK_RANGE explicitly"; exit 1; } @test -n "$(SPEC_HASH)" || { echo "error: could not hash $(SPEC) with '$(SHA256)'"; exit 1; } @test -n "$(TEMPLATES_HASH)" || { echo "error: could not hash $(TEMPLATES)/ and $(STATIC)/ with '$(SHA256)'"; exit 1; } @test -n "$(CONFIG_HASH)" || { echo "error: could not hash the generation config with '$(SHA256)'"; exit 1; } @@ -135,21 +108,18 @@ generate: -v "package=api" \ -v "pluginName=$(PLUGIN)" \ -v "version=$(VERSION)" \ - -v "sdkVersion=$(SDK_RANGE)" \ - -v "clientImport=$(CLIENT_IMPORT)" \ -v "specHash=$(SPEC_HASH)" \ -v "templatesHash=$(TEMPLATES_HASH)" \ -v "configHash=$(CONFIG_HASH)" cp -R $(STATIC)/. "$(BUILD)/" - @echo "generated @unikraft/cloud-plugin-$(PLUGIN)-api@$(VERSION) (peer @unikraft/cloud $(SDK_RANGE)) -> $(BUILD)" + @echo "generated @unikraft/cloud-plugin-$(PLUGIN)-api@$(VERSION) -> $(BUILD)" ## Generate and compile one plugin into an ESM package. # -# The SDK goes on the install line positionally, which satisfies the -# peerDependency from a local tarball or path, so npm never queries the registry -# for the peer while the SDK is unpublished. +# The package has no runtime dependencies: the Transport contract is one of its +# own files, so the install brings in the compiler and the formatter only. build: generate - cd "$(BUILD)" && $(NPM) install --no-save --no-audit --no-fund "$(SDK_SPEC_ABS)" + cd "$(BUILD)" && $(NPM) install --no-save --no-audit --no-fund -cd "$(BUILD)" && $(NPX) --no-install @biomejs/biome format --write src cd "$(BUILD)" && $(NPM) run build @echo "built $(BUILD)/dist" diff --git a/js/tools/tsplugingen/README.md b/js/tools/tsplugingen/README.md index c6b4cdd..e1116d6 100644 --- a/js/tools/tsplugingen/README.md +++ b/js/tools/tsplugingen/README.md @@ -17,16 +17,9 @@ registry. - **Input:** `/openapi.yaml` in the plugins repository. - **Generator:** [`openapi-gen`](https://github.com/unikraft-cloud/x/tree/prod-staging/tools/openapi-gen) with the templates in [`templates/`](templates). These templates are the - platform SDK's own templates, changed to use an external transport. -- **Peer dependency:** `@unikraft/cloud`. The generated classes extend its - `ApiClient`. - -> **Needs `@unikraft/cloud` >= 0.1.1.** `CLIENT_IMPORT` defaults to the -> `@unikraft/cloud/core/http` subpath, which -> [js-sdk#26](https://github.com/unikraft-cloud/js-sdk/pull/26) adds. Until -> that lands and publishes, `build` cannot resolve the peer and npm fails with -> `notarget`. Point `SDK_SPEC` at a local js-sdk checkout or tarball to build -> before then. + platform SDK's own templates, changed to take an external transport. +- **Dependencies:** none. The generated classes take a `Transport`, and the + contract for it ships inside the package. See below. ## Generated code only @@ -40,7 +33,29 @@ package and wraps it. For the same reason, this package does not derive the base URL from the specification. `servers` in `api.tsp` hardcodes the `sandbox` segment, but a plugin answers under the name that you attach it with. The SDK builds the URL in `src/core/plugin.ts` instead, and this package takes a -finished `baseUrl`. +finished transport. + +## The transport contract + +Every generated class takes one argument, a `Transport`: an object with +`request()`, `bytes()` and `stream()`. The SDK's `ApiClient` in +`@unikraft/cloud/core/http` is one. The type is structural, so any object with +these three methods is one too. + +The contract is `templates/transport.ts.tmpl`, which `generate` writes to +`src/transport.ts` in the package. It is the only copy of the interface. The +package therefore needs no dependency on `@unikraft/cloud`, and a caller cannot +get two copies of the SDK in one tree. + +js-sdk holds itself to the contract. `ApiClient` satisfies the interface by +shape, a comment on the class points here, and the js-sdk test suite checks +`ApiClient` against the published plugin package. The header of the file names +the changes that are safe (a new optional parameter, a wider input type) and +the ones that break every plugin package (a rename, a new required parameter, a +changed return type). A breaking change needs the same change in `ApiClient` +and every plugin package republished in the same cycle. Because the file is a +template, `templatesHash` covers it, so a changed contract makes `publish` +refuse to skip a plugin that npm holds under the old one. ## The specifications are not in the repository @@ -59,7 +74,7 @@ already flattens its shared schema names with `@@friendlyName`. ## Usage ```sh -make config # show the resolved channel, plugins and peer range +make config # show the resolved channel, plugins and generator make list # list the plugins that have a compiled specification make plugin-sandbox # generate and build one plugin make all # every plugin @@ -70,53 +85,6 @@ The build writes its output to `.build//dist`, which git ignores. The generated package builds with TypeScript 7 and formats with Biome 2. -## Do not hard-code the peer range - -The Makefile computes `SDK_RANGE` from the `@unikraft/cloud` version that the -channel resolves to. Do not replace it with a constant such as `^0.1.0`. - -npm's semver excludes a prerelease from a range unless some comparator carries a -prerelease tag on the same `major.minor.patch`. js-sdk publishes its staging -channel as `0.1.1-next.N`. Neither `^0.1.0` nor `>=0.1.0` matches that version. - -npm does not report the mismatch as a conflict. npm installs a **second copy** -of `@unikraft/cloud` under this package instead. The tree then holds two -`ApiClient` classes and two `UnikraftCloudError` classes. Every `instanceof` -check that a caller makes against the SDK's error type returns `false`. The -install prints no warning. - -On a prerelease channel, the Makefile adds a `-0` lower bound, so the range -matches: - -| SDK version | Derived range | -| -------------- | ------------------ | -| `0.0.3` | `>=0.0.3 <0.0.4` | -| `0.1.0` | `>=0.1.0 <0.2.0` | -| `0.1.1-next.0` | `>=0.1.1-0 <0.2.0` | -| `1.2.3` | `>=1.2.3 <2.0.0` | - -### A prerelease range expires at the next stable release - -npm ties the prerelease exemption to one exact `major.minor.patch`, so a -derived range covers a whole staging cycle and then stops: - -| SDK version | `>=0.1.1-0 <0.2.0` | -| -------------- | ------------------ | -| `0.1.1-next.0` | matches | -| `0.1.1-next.9` | matches | -| `0.1.1` | matches | -| `0.1.2` | matches | -| `0.1.2-next.0` | **no match** | - -The range survives every `next.N` bump. It expires on one event: staging opens -the next patch, which happens when stable ships `0.1.1`. A -plugin published before that point would quietly get a second `@unikraft/cloud` -nested under it. - -`configHash` below is what catches this. The peer range is part of the -published fingerprint, so at that boundary `publish` fails instead of skipping, -and the plugin has to be regenerated and republished against the new range. - ## A publish checks the sources that it came from The package version is the OpenAPI specification's `info.version`, and nothing forces @@ -140,12 +108,12 @@ the hashes into the package: | --------------- | ----------------------------------------------------- | | `specHash` | `/openapi.yaml` | | `templatesHash` | `templates/` and `static/` | -| `configHash` | `SDK_RANGE`, `CLIENT_IMPORT` and `OPENAPI_GEN` | +| `configHash` | `OPENAPI_GEN` | The specification is not the only thing that moves. A template fix changes the -generated code while the specification stands still, and so does a new peer -range, a different client import, or a new generator revision. None of these -leave a mark in a file that the first two hashes cover. +generated code while the specification stands still, and so does a new +generator revision. Neither leaves a mark in a file that the first hash +covers. By default, `OPENAPI_GEN` names the exact revision that the `CHANNEL` branch resolves to, so a new commit on that branch changes `configHash`. An @@ -174,11 +142,8 @@ report success. | Variable | Default | Description | | --------------- | ------------------------------------------------------ | --------------------------------------------------------------------- | | `SPEC_ROOT` | `../../../../plugins` | Root that holds `/openapi.yaml`. | -| `CHANNEL` | `prod-staging` | Release channel. Sets the generator branch, the dist-tag and the SDK. | +| `CHANNEL` | `prod-staging` | Release channel. Sets the generator branch and the dist-tag. | | `OPENAPI_GEN` | `go run unikraft.com/x/tools/openapi-gen@` | The generator command. `` is the commit that `CHANNEL` resolves to. | -| `CLIENT_IMPORT` | `@unikraft/cloud/core/http` | Where the generated classes import `ApiClient` from. | -| `SDK_SPEC` | `@unikraft/cloud@` | npm package specifier for the peer. A tarball or a path also works, and is resolved relative to this directory. | -| `SDK_RANGE` | _derived from_ `SDK_SPEC` | The peer range that `generate` writes into `package.json`. See above. | | `BUILD_ROOT` | `.build` | Directory that `generate` writes each plugin into. | | `PUBLISH_FLAGS` | _(empty)_ | Extra `npm publish` flags. CI passes `--provenance`. | @@ -189,5 +154,6 @@ report success. | `models.ts.tmpl` | `src/api/models.gen.ts` | request and response models, enums | | `resources.tmpl` | `src/api/*.gen.ts`, `index.gen.ts` | one `…Api` class per tag, and a barrel | | `index.ts.tmpl` | `src/index.ts` | the container class that groups them | -| `package.json.tmpl` | `package.json` | manifest, exports, peer range | +| `transport.ts.tmpl` | `src/transport.ts` | the `Transport` contract, verbatim | +| `package.json.tmpl` | `package.json` | manifest and exports | | `README.md.tmpl` | `README.md` | the per-package readme | diff --git a/js/tools/tsplugingen/publish-check.sh b/js/tools/tsplugingen/publish-check.sh index 89111c5..449f618 100755 --- a/js/tools/tsplugingen/publish-check.sh +++ b/js/tools/tsplugingen/publish-check.sh @@ -92,7 +92,7 @@ echo "error: $name@$ver is on npm, but it came from different sources." >&2 [ "$was_tmpl" = "$tmpl_hash" ] || echo " The generator templates or the static build config changed." >&2 [ "$was_cfg" = "$cfg_hash" ] || - echo " The peer range, the client import or the generator changed." >&2 + echo " The generator changed." >&2 echo " A new version must ship the change." >&2 echo " on npm: spec=$was_spec templates=$was_tmpl config=$was_cfg" >&2 echo " current: spec=$spec_hash templates=$tmpl_hash config=$cfg_hash" >&2 diff --git a/js/tools/tsplugingen/sdk-range.sh b/js/tools/tsplugingen/sdk-range.sh deleted file mode 100755 index 0947af1..0000000 --- a/js/tools/tsplugingen/sdk-range.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/bin/sh -# SPDX-License-Identifier: BSD-3-Clause -# Copyright (c) 2026, Unikraft GmbH. All rights reserved. -# -# Print the @unikraft/cloud peer range for a given npm spec, or nothing when the -# spec cannot be resolved. -# -# Do not replace this derivation with a hard-coded range. npm's semver excludes a -# prerelease from a range unless some comparator carries a prerelease tag on the -# same major.minor.patch, so "^0.1.0" and ">=0.1.0" both fail to match an SDK -# published as 0.1.1-next.N. npm does not report that as a conflict: it -# satisfies the peer by nesting a second copy of @unikraft/cloud, which gives -# the tree two ApiClient and two UnikraftCloudError classes and silently breaks -# every `instanceof` check a caller makes. -# -# Usage: sdk-range.sh -set -eu - -# The Makefile exports these so `make NPM=... NODE=...` reaches here too. -NPM=${NPM:-npm} -NODE=${NODE:-node} - -spec=${1:-} -[ -n "$spec" ] || exit 0 - -version='' -case "$spec" in -*.tgz) - version=$(tar -xzOf "$spec" package/package.json 2>/dev/null | - $NODE -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null) || true - ;; -/* | ./* | ../*) - version=$($NODE -p "require('$spec/package.json').version" 2>/dev/null) || true - ;; -*) - version=$($NPM view "$spec" version 2>/dev/null | tail -n1) || true - ;; -esac - -[ -n "$version" ] || exit 0 - -# js-sdk keeps a 0.0.0 placeholder in package.json, and its release workflow -# stamps the real version at publish time. A local checkout therefore reads -# 0.0.0, and the derived range (">=0.0.0 <0.0.1") matches no published SDK. -# Treat 0.0.0 as unresolved, so the Makefile fails with its "set SDK_RANGE -# explicitly" message. -[ "$version" != 0.0.0 ] || exit 0 - -$NODE -e ' -const v = process.argv[1]; -const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v); -if (!m) process.exit(0); -const [, major, minor, patch] = m; -// A prerelease lower bound needs its own prerelease tag on the same tuple. -// Test the prerelease position only: a hyphen in build metadata ("1.2.3+b-7") -// is not a prerelease and must not loosen the bound. -const lower = /^\d+\.\d+\.\d+-/.test(v) ? `${major}.${minor}.${patch}-0` : `${major}.${minor}.${patch}`; -// Pre-1.0 packages break on the minor, and 0.0.z breaks on every patch, so -// each stops the range one step above where it can still be compatible. -const upper = - major === "0" - ? minor === "0" - ? `0.0.${+patch + 1}` - : `0.${+minor + 1}.0` - : `${+major + 1}.0.0`; -console.log(`>=${lower} <${upper}`); -' "$version" diff --git a/js/tools/tsplugingen/static/tsconfig.json b/js/tools/tsplugingen/static/tsconfig.json index 71641f0..acfbabb 100644 --- a/js/tools/tsplugingen/static/tsconfig.json +++ b/js/tools/tsplugingen/static/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "module": "node20", "target": "ES2022", - "lib": ["ES2024", "ESNext.Array", "ESNext.Collection", "ESNext.Iterator"], + "lib": ["ES2024", "ESNext.Array", "ESNext.Collection", "ESNext.Iterator", "DOM"], "types": [], diff --git a/js/tools/tsplugingen/templates/README.md.tmpl b/js/tools/tsplugingen/templates/README.md.tmpl index 81d9b21..4eb2e24 100644 --- a/js/tools/tsplugingen/templates/README.md.tmpl +++ b/js/tools/tsplugingen/templates/README.md.tmpl @@ -17,11 +17,13 @@ this package and creates the instance for you. ## Install ```sh -npm install @unikraft/cloud-plugin-{{ $plugin }}-api @unikraft/cloud +npm install @unikraft/cloud-plugin-{{ $plugin }}-api ``` -`@unikraft/cloud` is a peer dependency, because the generated classes extend -its `ApiClient` transport. +The package has no dependencies. Each generated class takes a `Transport`, an +object with `request()`, `bytes()` and `stream()`. The `ApiClient` of +`@unikraft/cloud` is one. If you want to use it as the transport, install the +SDK. ## Usage @@ -29,12 +31,15 @@ The plugin runs inside a single instance and answers on that instance's plugin route, so `baseUrl` is the full plugin endpoint rather than a metro root. ```ts +import { ApiClient } from "@unikraft/cloud/core/http"; import { {{ $class }} } from "@unikraft/cloud-plugin-{{ $plugin }}-api"; -const api = new {{ $class }}({ - baseUrl: `https://api.fra.unikraft.cloud/v1/instances/${uuid}/plugins/{{ $plugin }}`, - token: process.env.UKC_TOKEN, -}); +const api = new {{ $class }}( + new ApiClient({ + baseUrl: `https://api.fra.unikraft.cloud/v1/instances/${uuid}/plugins/{{ $plugin }}`, + token: process.env.UKC_TOKEN, + }), +); ``` The last path segment is whatever name the plugin was attached under in the @@ -45,7 +50,7 @@ SDK build that URL for you, use `@unikraft/cloud` instead. | Import | Carries | | --- | --- | -| `@unikraft/cloud-plugin-{{ $plugin }}-api` | `{{ $class }}`{{ range $tags }}, `{{ pascalcase . }}Api`{{ end }}, `models` | +| `@unikraft/cloud-plugin-{{ $plugin }}-api` | `{{ $class }}`{{ range $tags }}, `{{ pascalcase . }}Api`{{ end }}, `models`, and the types `Transport`, `RequestArgs`, `CallOptions` | | `@unikraft/cloud-plugin-{{ $plugin }}-api/models` | The wire types | `{{ $class }}` groups one client per resource: diff --git a/js/tools/tsplugingen/templates/index.ts.tmpl b/js/tools/tsplugingen/templates/index.ts.tmpl index 215458b..a56802d 100644 --- a/js/tools/tsplugingen/templates/index.ts.tmpl +++ b/js/tools/tsplugingen/templates/index.ts.tmpl @@ -1,7 +1,6 @@ {{- $ops := .Operations -}} {{- $tags := uniqueTags $ops -}} {{- $plugin := .Var "pluginName" "plugin" -}} -{{- $clientImport := .Var "clientImport" "@unikraft/cloud/core/http" -}} {{- $class := printf "%sPluginApi" (pascalcase $plugin) -}} {{- /* pascalcase keeps a leading digit, which TypeScript rejects in a class name. */ -}} @@ -12,24 +11,26 @@ // Code generated by openapi-gen; DO NOT EDIT. // // The `{{ $plugin }}` plugin API's "plumbing" layer: the generated clients, plus a -// container that groups them behind one transport config. Everything here -// returns the response envelope exactly as the OpenAPI specification describes -// it. +// container that groups them behind one transport. Everything here returns the +// response envelope exactly as the OpenAPI specification describes it. {{ range $tag := $tags }}import { {{ pascalcase $tag }}Api } from "./api/{{ kebabcase $tag }}.gen.js"; {{ end }} -import type { ApiClientConfig } from "{{ $clientImport }}"; +import type { Transport } from "./transport.js"; /** * The porcelain layer in `@unikraft/cloud` derives the URL. Use this class * only when you want the raw envelopes against an instance you already have. * * @example + * import { ApiClient } from "@unikraft/cloud/core/http"; * import { {{ $class }} } from "@unikraft/cloud-plugin-{{ $plugin }}-api"; * - * const api = new {{ $class }}({ - * baseUrl: `https://api.fra.unikraft.cloud/v1/instances/${uuid}/plugins/{{ $plugin }}`, - * token: process.env.UKC_TOKEN, - * }); + * const api = new {{ $class }}( + * new ApiClient({ + * baseUrl: `https://api.fra.unikraft.cloud/v1/instances/${uuid}/plugins/{{ $plugin }}`, + * token: process.env.UKC_TOKEN, + * }), + * ); */ export class {{ $class }} { {{- range $tag := $tags }} @@ -37,11 +38,19 @@ export class {{ $class }} { readonly {{ camelcase $tag }}: {{ pascalcase $tag }}Api; {{- end }} - constructor(config: ApiClientConfig) { + constructor(transport: Transport) { + for (const method of ["request", "bytes", "stream"] as const) { + if (typeof transport[method] !== "function") { + throw new TypeError( + `The transport has no ${method}() method. Pass an ApiClient from @unikraft/cloud/core/http, or an object with request(), bytes() and stream().`, + ); + } + } {{- range $tag := $tags }} - this.{{ camelcase $tag }} = new {{ pascalcase $tag }}Api(config); + this.{{ camelcase $tag }} = new {{ pascalcase $tag }}Api(transport); {{- end }} } } export * from "./api/index.gen.js"; +export type { CallOptions, RequestArgs, Transport } from "./transport.js"; diff --git a/js/tools/tsplugingen/templates/package.json.tmpl b/js/tools/tsplugingen/templates/package.json.tmpl index 69e38d9..9393308 100644 --- a/js/tools/tsplugingen/templates/package.json.tmpl +++ b/js/tools/tsplugingen/templates/package.json.tmpl @@ -1,6 +1,5 @@ {{- $plugin := .Var "pluginName" "plugin" -}} {{- $version := .Var "version" "0.0.0" -}} -{{- $sdk := .Var "sdkVersion" "*" -}} {{- $specHash := .Var "specHash" "" -}} {{- $templatesHash := .Var "templatesHash" "" -}} {{- $configHash := .Var "configHash" "" -}} @@ -40,9 +39,6 @@ "typecheck": "tsc --noEmit", "format": "biome format --write src" }, - "peerDependencies": { - "@unikraft/cloud": "{{ $sdk }}" - }, "devDependencies": { "@biomejs/biome": "^2.5.13", "typescript": "^7.0.2" diff --git a/js/tools/tsplugingen/templates/resources.tmpl b/js/tools/tsplugingen/templates/resources.tmpl index 18ee3c3..e72b71e 100644 --- a/js/tools/tsplugingen/templates/resources.tmpl +++ b/js/tools/tsplugingen/templates/resources.tmpl @@ -1,5 +1,4 @@ {{- $ops := .Operations -}} -{{- $clientImport := .Var "clientImport" "@unikraft/cloud/core/http" -}} {{- range $op := $ops }}{{ if not $op.Operation.Tags }} {{- fail (printf "%s %s has no tag, so no generated class can expose it" $op.Method $op.Path) }} {{- end }}{{ end -}} @@ -21,8 +20,8 @@ {{- range $op := $ops -}} {{- if has $tag $op.Operation.Tags -}} {{- $method := tsSafeName (camelcase $op.Operation.OperationID) -}} -{{- if has $method (list "request" "stream" "bytes") -}} -{{- fail (printf "operation %q renders the method %s, which ApiClient already defines; rename it in the plugin's api.tsp" $op.Operation.OperationID $method) -}} +{{- if eq $method "transport" -}} +{{- fail (printf "operation %q renders the method %s, which is the name of the class's transport property; rename it in the plugin's api.tsp" $op.Operation.OperationID $method) -}} {{- end -}} {{- if has $method $methods -}} {{- fail (printf "operation %q renders the method %s under tag %q, and another operation renders it too; rename one of them in the plugin's api.tsp" $op.Operation.OperationID $method $tag) -}} @@ -34,14 +33,20 @@ {{- range $tag := uniqueTags $ops }} --- src/api/{{ kebabcase $tag }}.gen.ts // Code generated by openapi-gen; DO NOT EDIT. -import { ApiClient, type CallOptions } from "{{ $clientImport }}"; import type * as models from "./models.gen.js"; +import type { CallOptions, Transport } from "../transport.js"; /** * Low-level "plumbing" client for the {@link https://unikraft.com|Unikraft * Cloud} `{{ $tag }}` resource. Methods return the raw response envelope. */ -export class {{ pascalcase $tag }}Api extends ApiClient { +export class {{ pascalcase $tag }}Api { + /** The transport every request of this resource goes through. */ + readonly transport: Transport; + + constructor(transport: Transport) { + this.transport = transport; + } {{- range $op := $ops }} {{- if has $tag $op.Operation.Tags }} {{ template "method" $op }} @@ -53,7 +58,7 @@ export class {{ pascalcase $tag }}Api extends ApiClient { // Code generated by openapi-gen; DO NOT EDIT. // // Barrel for the generated "plumbing" API. -export type { ApiClient, CallOptions } from "{{ $clientImport }}"; +export type { CallOptions, RequestArgs, Transport } from "../transport.js"; export * as models from "./models.gen.js"; {{ range $tag := uniqueTags $ops }}export { {{ pascalcase $tag }}Api } from "./{{ kebabcase $tag }}.gen.js"; {{ end }} @@ -68,8 +73,8 @@ export * as models from "./models.gen.js"; {{- fail (printf "%s %s: path parameter %q is not a scalar, so encodeURIComponent cannot serialize it" $po.Method $po.Path .Name) -}} {{- end -}} {{- end -}} -{{- /* ApiClient does not escape `path`, so a "/" or "?" in a path parameter - would change the route. */ -}} +{{- /* The transport does not escape `path`, so a "/" or "?" in a path + parameter would change the route. */ -}} {{- range pathParameters $po }}{{ $path = replace (printf "{%s}" .Name) (printf "${encodeURIComponent(%s)}" (tsSafeName .Name)) $path }}{{ end -}} {{- /* Next to a 2xx response, `default` describes the errors. This is the same rule as in responseJSONSchema. */ -}} @@ -82,7 +87,7 @@ export * as models from "./models.gen.js"; {{- if eq $entry.Code "default" -}}{{- $success = append $success $entry -}}{{- end -}} {{- end -}} {{- end -}} -{{- /* A method makes one ApiClient call, and each call decodes one media +{{- /* A method makes one transport call, and each call decodes one media type. A media type without a schema still has a body, so it is unknown, not void. */ -}} {{- $ret := "void" -}} @@ -138,7 +143,7 @@ export * as models from "./models.gen.js"; {{- end -}} {{- end -}}{{- end -}} {{- end -}} -{{- /* Browsers forbid a Cookie header in fetch, and ApiClient has no cookie +{{- /* Browsers forbid a Cookie header in fetch, and CallOptions has no cookie option. */ -}} {{- range $op.Parameters -}}{{- with .Value -}}{{- if eq .In "cookie" -}} {{- fail (printf "%s %s: parameter %q is a cookie, which the templates cannot send" $po.Method $po.Path .Name) -}} @@ -155,8 +160,8 @@ export * as models from "./models.gen.js"; {{- $bodyRef = false -}} {{- $bodyReq = false -}} {{- end -}} -{{- /* The method passes `params` to ApiClient, which reads signal, headers - and baseUrl from it. */ -}} +{{- /* The method passes `params` to the transport, which reads signal, + headers and baseUrl from it. */ -}} {{- $reserved := list "signal" "headers" "baseUrl" -}} {{- if $bodyRef -}}{{- $reserved = append $reserved "body" -}}{{- end -}} {{- range $query -}}{{- if has .Name $reserved -}} @@ -204,7 +209,7 @@ export * as models from "./models.gen.js"; params: CallOptions = {}, {{- end }} ): {{ if $sse }}AsyncGenerator<{{ $ret }}, void, void>{{ else }}Promise<{{ $ret }}>{{ end }} { - return this.{{ if $sse }}stream<{{ $ret }}>{{ else if $oct }}bytes{{ else }}request<{{ $ret }}>{{ end }}( + return this.transport.{{ if $sse }}stream<{{ $ret }}>{{ else if $oct }}bytes{{ else }}request<{{ $ret }}>{{ end }}( { method: "{{ .Method }}", path: `{{ $path }}`, diff --git a/js/tools/tsplugingen/templates/transport.ts.tmpl b/js/tools/tsplugingen/templates/transport.ts.tmpl new file mode 100644 index 0000000..9a772eb --- /dev/null +++ b/js/tools/tsplugingen/templates/transport.ts.tmpl @@ -0,0 +1,55 @@ +{{- /* The contract has no template logic. It is a template so that it ships + through the same path as every other source file and templatesHash + covers it. */ -}} +--- src/transport.ts +// Code generated by openapi-gen; DO NOT EDIT. +// +// The contract between this package and the transport that carries its +// requests. `ApiClient` in `@unikraft/cloud/core/http` implements it, and any +// object with these three methods satisfies it, because the type is structural. +// This file imports nothing, so the package needs no dependency on the SDK. +// +// This is the only copy of the interface. The SDK's `ApiClient` satisfies it by +// shape, and the SDK's test suite checks that against the published plugin +// package. Widening is safe: a new optional parameter, a wider input type. A +// rename, a new required parameter, or a changed return type must reach +// `ApiClient` in the SDK as well, and every plugin package must be republished. + +/** A value that can be serialised into a query string. */ +export type QueryValue = + | string + | number + | boolean + | Array + | undefined + | null; + +/** Per-call options accepted by every generated operation. */ +export interface CallOptions { + /** Abort the request via an `AbortSignal`. */ + signal?: AbortSignal; + /** Extra headers merged over the client defaults for this call only. */ + headers?: Record; + /** Override the base URL (e.g. to target a different metro) for this call. */ + baseUrl?: string; +} + +/** The shape of one request, as a generated operation hands it to the transport. */ +export interface RequestArgs { + method: string; + path: string; + query?: Record; + body?: unknown; +} + +/** + * What a generated class needs from the SDK: one method per response shape. + */ +export interface Transport { + /** Perform a request and return the parsed JSON envelope typed as `T`. */ + request(args: RequestArgs, options?: CallOptions): Promise; + /** Perform a request whose response is a raw byte body and return it verbatim. */ + bytes(args: RequestArgs, options?: CallOptions): Promise; + /** Perform a request whose response is a `text/event-stream` and yield each event's payload. */ + stream(args: RequestArgs, options?: CallOptions): AsyncGenerator; +}