From da5fc10c4cfd95fb1473c3cb0d93e995b6203967 Mon Sep 17 00:00:00 2001 From: aabedraba Date: Mon, 14 Sep 2026 21:01:14 +0200 Subject: [PATCH] feat(js): Create tsplugingen Signed-off-by: aabedraba --- js/tools/tsplugingen/.gitignore | 2 + js/tools/tsplugingen/Makefile | 169 +++++++++++++ js/tools/tsplugingen/README.md | 193 ++++++++++++++ js/tools/tsplugingen/publish-check.sh | 99 ++++++++ js/tools/tsplugingen/sdk-range.sh | 58 +++++ js/tools/tsplugingen/static/biome.json | 25 ++ js/tools/tsplugingen/static/tsconfig.json | 32 +++ js/tools/tsplugingen/templates/README.md.tmpl | 54 ++++ js/tools/tsplugingen/templates/index.ts.tmpl | 48 ++++ js/tools/tsplugingen/templates/models.ts.tmpl | 67 +++++ .../tsplugingen/templates/package.json.tmpl | 58 +++++ js/tools/tsplugingen/templates/resources.tmpl | 237 ++++++++++++++++++ 12 files changed, 1042 insertions(+) create mode 100644 js/tools/tsplugingen/.gitignore create mode 100644 js/tools/tsplugingen/Makefile create mode 100644 js/tools/tsplugingen/README.md create mode 100755 js/tools/tsplugingen/publish-check.sh create mode 100755 js/tools/tsplugingen/sdk-range.sh create mode 100644 js/tools/tsplugingen/static/biome.json create mode 100644 js/tools/tsplugingen/static/tsconfig.json create mode 100644 js/tools/tsplugingen/templates/README.md.tmpl create mode 100644 js/tools/tsplugingen/templates/index.ts.tmpl create mode 100644 js/tools/tsplugingen/templates/models.ts.tmpl create mode 100644 js/tools/tsplugingen/templates/package.json.tmpl create mode 100644 js/tools/tsplugingen/templates/resources.tmpl diff --git a/js/tools/tsplugingen/.gitignore b/js/tools/tsplugingen/.gitignore new file mode 100644 index 0000000..4a56afc --- /dev/null +++ b/js/tools/tsplugingen/.gitignore @@ -0,0 +1,2 @@ +.build/ +*.tgz diff --git a/js/tools/tsplugingen/Makefile b/js/tools/tsplugingen/Makefile new file mode 100644 index 0000000..b8224ad --- /dev/null +++ b/js/tools/tsplugingen/Makefile @@ -0,0 +1,169 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Build one npm package per plugin, @unikraft/cloud-plugin--api, from the +# plugin's OpenAPI spec. See README.md. + +# The plugins repository holds /api.tsp, not /openapi.yaml, so +# compile the spec there first. +SPEC_ROOT ?= ../../../../plugins +BUILD_ROOT ?= .build +TEMPLATES ?= ./templates +STATIC ?= ./static + +GO ?= go +SHA256 ?= $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo shasum -a 256) +NPM ?= npm +NPX ?= npx +NODE ?= node + +# sdk-range.sh and publish-check.sh run 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. +CHANNEL ?= prod-staging +DIST_TAG = $(if $(filter prod-stable,$(CHANNEL)),latest,next) + +# The generator follows the channel too. A prod-stable build must not render +# its plugins with staging's generator. +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 a +# relative local spec would be looked up 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 range from "0.1.1-next.N" turns to ">=0.1.1-0 <0.2.0" +# sdk-range.sh explains why. +SDK_RANGE ?= $(shell ./sdk-range.sh $(SDK_SPEC_ABS)) + +PUBLISH_FLAGS ?= + +# Every plugin under SPEC_ROOT that has a compiled spec. +PLUGINS := $(notdir $(patsubst %/,%,$(dir $(wildcard $(SPEC_ROOT)/*/openapi.yaml)))) + +SPEC = $(SPEC_ROOT)/$(PLUGIN)/openapi.yaml +BUILD = $(BUILD_ROOT)/$(PLUGIN) +# info.version, with YAML quotes and any leading "v" stripped for semver. +VERSION = $(shell awk '/^info:/{f=1;next} /^[^[:space:]#]/{f=0} f&&/^[[:space:]]+version:/{v=$$2; gsub(/["\047]/,"",v); sub(/^v/,"",v); if (v ~ /^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$$/) print v; exit}' $(SPEC)) + +# The package version is the spec's info.version, and nothing forces that line +# to move when the sources do. publish compares these against the published +# version, so a changed source under an unchanged version fails instead of +# silently reporting success. +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) + +.PHONY: all list clean generate build publish publish-all config check-plugins + +check-plugins: + @test -n "$(PLUGINS)" || { \ + echo "error: no plugin specs under SPEC_ROOT=$(SPEC_ROOT)"; \ + echo " The plugins repository holds /api.tsp, not /openapi.yaml."; \ + echo " Compile it there ('npx tsp compile /api.tsp'), or set SPEC_ROOT."; \ + exit 1; } + +## Build every plugin. +all: check-plugins + @for p in $(PLUGINS); do $(MAKE) --no-print-directory build PLUGIN=$$p || exit 1; done + +## Build and publish every plugin whose version is not on npm yet. +publish-all: check-plugins + @for p in $(PLUGINS); do $(MAKE) --no-print-directory publish PLUGIN=$$p || exit 1; done + +## List the plugins that have a compiled spec. +list: + @echo $(PLUGINS) + +## Print the resolved channel configuration. +config: + @echo "channel: $(CHANNEL)" + @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)" + +## Build one plugin, e.g. `make plugin-sandbox`. +plugin-%: + @$(MAKE) --no-print-directory build PLUGIN=$* + +## Generate one plugin's TypeScript, without compiling it. +generate: + @test -n "$(PLUGIN)" || { echo "error: set PLUGIN="; exit 1; } + @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; } + rm -rf "$(BUILD)" + mkdir -p "$(BUILD)" + $(OPENAPI_GEN) \ + -i "$(SPEC)" \ + -o "$(BUILD)" \ + -t "$(TEMPLATES)" \ + -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)" + +## 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. +build: generate + cd "$(BUILD)" && $(NPM) install --no-save --no-audit --no-fund "$(SDK_SPEC_ABS)" + -cd "$(BUILD)" && $(NPX) --no-install @biomejs/biome format --write src + cd "$(BUILD)" && $(NPM) run build + @echo "built $(BUILD)/dist" + +## Build one plugin and publish it, unless npm already carries that version +## built from the same sources. Needs an authenticated npm (NPM_TOKEN in CI). +## +## publish-check.sh decides: 0 publish it, 10 skip it, anything else is a +## failure it has already explained. +publish: build + @cd "$(BUILD)" && rc=0; \ + "$(CURDIR)/publish-check.sh" "$(SPEC_HASH)" "$(TEMPLATES_HASH)" "$(CONFIG_HASH)" || rc=$$?; \ + case $$rc in \ + 0) echo "publishing (tag: $(DIST_TAG))"; \ + $(NPM) publish --access public --tag "$(DIST_TAG)" $(PUBLISH_FLAGS) ;; \ + 10) : ;; \ + *) exit $$rc ;; \ + esac + +## Remove all build output. +clean: + rm -rf "$(BUILD_ROOT)" diff --git a/js/tools/tsplugingen/README.md b/js/tools/tsplugingen/README.md new file mode 100644 index 0000000..b443aa9 --- /dev/null +++ b/js/tools/tsplugingen/README.md @@ -0,0 +1,193 @@ +# tsplugingen + +`tsplugingen` builds a TypeScript client for each Unikraft Cloud plugin. It +publishes each client to npm as `@unikraft/cloud-plugin--api`. + +Its Go counterpart is [`sdkgen`](../../../go/tools/sdkgen), which renders a tree +in the [Go module proxy][goproxy] format. The package host serves that tree +directly to `go get`. npm has no mechanism that generates a package at install +time, so this tool builds and publishes in advance. CI renders each plugin's +specification to a finished npm package, then pushes the package to the +registry. + +[goproxy]: https://go.dev/ref/mod#goproxy-protocol + +- **Package:** `@unikraft/cloud-plugin--api` on npm. +- **Version:** the plugin's OpenAPI `info.version`, without a leading `v`. +- **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. + +## Generated code only + +The package holds only generated code: one class per OpenAPI tag, methods that +carry the name of their `operationId`, and responses that keep the raw +envelope. + +The layer with the ergonomic API is **not** here. It lives in +[`js-sdk`](https://github.com/unikraft-cloud/js-sdk), which depends on this +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`. + +## The specifications are not in the repository + +The plugins repository holds `/api.tsp`, not `/openapi.yaml`. +Compile the specification there before you generate a client: + +```sh +cd ../../../../plugins +npm ci +npx tsp compile sandbox/api.tsp --warn-as-error +``` + +A plugin specification does not need `--namespace-flatten=strip`. `api.tsp` +already flattens its shared schema names with `@@friendlyName`. + +## Usage + +```sh +make config # show the resolved channel, plugins and peer range +make list # list the plugins that have a compiled specification +make plugin-sandbox # generate and build one plugin +make all # every plugin +make publish-all # publish everything that is not on npm yet +``` + +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 +opening the next patch, which is what 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 +that line to move when the specification changes. A changed specification under +an unchanged version therefore generates new code. The publish step finds the +version on npm, skips it, and reports success. js-sdk keeps the stale client, +and no step fails. + +To stop that, `generate` hashes every input that decides the output and writes +the hashes into the package: + +```json +"unikraft": { + "specHash": "0d07a5aa…", + "templatesHash": "ce52b1af…", + "configHash": "3ea3f657…" +} +``` + +| Hash | Covers | +| --------------- | ----------------------------------------------------- | +| `specHash` | `/openapi.yaml` | +| `templatesHash` | `templates/` and `static/` | +| `configHash` | `SDK_RANGE`, `CLIENT_IMPORT` and `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. + +By default, `OPENAPI_GEN` names the exact revision that the `CHANNEL` branch +resolves to, so a new commit on that branch changes `configHash`. An +`OPENAPI_GEN` that you set yourself, such as a local binary, enters the hash +only as its command text. + +`publish-check.sh` compares all three against the published version: + +| On npm | Result | +| ------------------------- | ----------------------------------------------- | +| Version absent | Publish it. | +| Present, all hashes match | Skip. A re-run of an unchanged channel is safe. | +| Present, any hash differs | **Fail**, and name the input that changed. | + +A version that npm received before these hashes existed carries none of them. +For that version, `publish` warns and skips, because there is nothing to +compare. + +A registry that cannot be reached is not an answer either way, so a failed +lookup fails the step. Reading it as "not published yet" would publish over the +check, and reading it as "carries no hash" would skip the check and report +success. + +## Configuration + +| 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. | +| `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`. | + +## Templates + +| File | Output | Contents | +| ------------------- | ---------------------------------- | -------------------------------------- | +| `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 | +| `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 new file mode 100755 index 0000000..23c665e --- /dev/null +++ b/js/tools/tsplugingen/publish-check.sh @@ -0,0 +1,99 @@ +#!/bin/sh +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, Unikraft GmbH. All rights reserved. +# +# Decide whether the package in the current directory should be published. +# +# The package version is the specification's info.version, and nothing forces +# that line to move when the sources do. A changed source under an unchanged +# version would otherwise regenerate the code, find the version on npm, skip +# it, and report success. So compare the fingerprints that `generate` wrote +# into package.json against the ones the published version carries. +# +# Usage: publish-check.sh +# +# Exit status: +# 0 not on npm, so publish it +# 10 on npm and built from these sources, so skip it +# 1 on npm from different sources, or the registry could not be reached +set -eu + +# The Makefile exports these so `make NPM=... NODE=...` reaches here too. +NPM=${NPM:-npm} +NODE=${NODE:-node} + +spec_hash=${1:?spec hash} +tmpl_hash=${2:?templates hash} +cfg_hash=${3:?config hash} + +name=$($NODE -p "require('./package.json').name") +ver=$($NODE -p "require('./package.json').version") + +err=$(mktemp) +trap 'rm -f "$err"' EXIT + +# `npm view` fails both when the version is absent and when the registry is +# unreachable. Only the first is a reason to publish, so classify the failure +# rather than read every one of them as "not published yet": a network or +# authentication blip must not slip past the comparison below. +if ! $NPM view "$name@$ver" version >/dev/null 2>"$err"; then + if grep -qE 'E404|404 Not Found' "$err"; then + echo "$name@$ver is not on npm yet" + exit 0 + fi + echo "error: could not ask npm whether $name@$ver exists" >&2 + cat "$err" >&2 + exit 1 +fi + +# Same reasoning for the metadata. An unreachable registry must not read as +# "this version carries no fingerprints", which would skip the check entirely +# and exit successfully. +if ! meta=$($NPM view "$name@$ver" unikraft --json 2>"$err"); then + echo "error: could not read the source fingerprints of $name@$ver" >&2 + cat "$err" >&2 + exit 1 +fi + +# An absent `unikraft` field leaves $meta empty, which is a published fact, not +# a lookup failure: the version predates these fingerprints. +field() { + printf '%s' "$meta" | $NODE -e ' + let s = ""; + process.stdin.on("data", (d) => (s += d)).on("end", () => { + let o = {}; + try { + o = JSON.parse(s) || {}; + } catch {} + console.log(o[process.argv[1]] || ""); + }); + ' "$1" +} + +was_spec=$(field specHash) +was_tmpl=$(field templatesHash) +was_cfg=$(field configHash) + +if [ -z "$was_spec" ]; then + echo "warning: $name@$ver predates source fingerprints, so it cannot be checked" + exit 10 +fi + +if [ "$was_spec" = "$spec_hash" ] && + [ "$was_tmpl" = "$tmpl_hash" ] && + [ "$was_cfg" = "$cfg_hash" ]; then + echo "$name@$ver is on npm and was built from these sources; skipping" + exit 10 +fi + +echo "error: $name@$ver is on npm, but it came from different sources." >&2 +[ "$was_spec" = "$spec_hash" ] || + echo " The specification changed. Bump info.version in its api.tsp." >&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 " 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 +exit 1 diff --git a/js/tools/tsplugingen/sdk-range.sh b/js/tools/tsplugingen/sdk-range.sh new file mode 100755 index 0000000..da90943 --- /dev/null +++ b/js/tools/tsplugingen/sdk-range.sh @@ -0,0 +1,58 @@ +#!/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. +# +# The range has to be derived rather than hard-coded. 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 + +$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. +const lower = v.includes("-") ? `${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/biome.json b/js/tools/tsplugingen/static/biome.json new file mode 100644 index 0000000..d048a75 --- /dev/null +++ b/js/tools/tsplugingen/static/biome.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.13/schema.json", + "files": { + "includes": ["src/**"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": false + }, + "assist": { + "enabled": false + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all", + "semicolons": "always" + } + } +} diff --git a/js/tools/tsplugingen/static/tsconfig.json b/js/tools/tsplugingen/static/tsconfig.json new file mode 100644 index 0000000..71641f0 --- /dev/null +++ b/js/tools/tsplugingen/static/tsconfig.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "module": "node20", + "target": "ES2022", + "lib": ["ES2024", "ESNext.Array", "ESNext.Collection", "ESNext.Iterator"], + + "types": [], + + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + + // The generated sources carry their own type-only markers, so the emit + // never has to guess which imports survive. + "verbatimModuleSyntax": true, + "isolatedModules": true, + // Keeps the output to syntax Node can strip on its own. + "erasableSyntaxOnly": true, + + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/js/tools/tsplugingen/templates/README.md.tmpl b/js/tools/tsplugingen/templates/README.md.tmpl new file mode 100644 index 0000000..bd3fb1c --- /dev/null +++ b/js/tools/tsplugingen/templates/README.md.tmpl @@ -0,0 +1,54 @@ +{{- $plugin := .Var "pluginName" "plugin" -}} +{{- $tags := uniqueTags .Operations -}} +{{- $class := printf "%sPluginApi" (pascalcase $plugin) -}} +--- README.md +# @unikraft/cloud-plugin-{{ $plugin }}-api + +The generated client for the Unikraft Cloud `{{ $plugin }}` plugin API. It is +generated from the plugin's OpenAPI specification, so **do not edit it by +hand**. + +This package is the raw ("plumbing") layer. Methods are named after their +`operationId` and return the response envelope exactly as the specification +describes it. If you want the idiomatic layer, use +[`@unikraft/cloud`](https://www.npmjs.com/package/@unikraft/cloud), which wraps +this package and creates the instance for you. + +## Install + +```sh +npm install @unikraft/cloud-plugin-{{ $plugin }}-api @unikraft/cloud +``` + +`@unikraft/cloud` is a peer dependency, because the generated classes extend +its `ApiClient` transport. + +## Usage + +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 { {{ $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, +}); +``` + +The last path segment is whatever name the plugin was attached under in the +instance's `plugins` array, so it is not necessarily `{{ $plugin }}`. To let the +SDK build that URL for you, use `@unikraft/cloud` instead. + +## Exports + +| Import | Carries | +| --- | --- | +| `@unikraft/cloud-plugin-{{ $plugin }}-api` | `{{ $class }}`{{ range $tags }}, `{{ pascalcase . }}Api`{{ end }}, `models` | +| `@unikraft/cloud-plugin-{{ $plugin }}-api/models` | The wire types | + +`{{ $class }}` groups one client per resource: +{{ range $tags }} +- `api.{{ camelcase . }}` — the `{{ . }}` resource +{{- end }} diff --git a/js/tools/tsplugingen/templates/index.ts.tmpl b/js/tools/tsplugingen/templates/index.ts.tmpl new file mode 100644 index 0000000..a1585f8 --- /dev/null +++ b/js/tools/tsplugingen/templates/index.ts.tmpl @@ -0,0 +1,48 @@ +{{- $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. */ -}} +{{- if not (regexMatch "^[A-Za-z_$][A-Za-z0-9_$]*$" $class) -}} +{{- fail (printf "plugin name %q cannot name a TypeScript class" $plugin) -}} +{{- end -}} +--- src/index.ts +// 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. +{{ range $tag := $tags }}import { {{ pascalcase $tag }}Api } from "./api/{{ kebabcase $tag }}.gen.js"; +{{ end }} +import type { ApiClientConfig } from "{{ $clientImport }}"; + +/** + * Deriving the URL is the porcelain layer's job, in `@unikraft/cloud`. Reach + * for this class only when you want the raw envelopes against an instance you + * already have. + * + * @example + * 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, + * }); + */ +export class {{ $class }} { +{{- range $tag := $tags }} + /** The `{{ $tag }}` resource of this plugin. */ + readonly {{ camelcase $tag }}: {{ pascalcase $tag }}Api; +{{- end }} + + constructor(config: ApiClientConfig) { +{{- range $tag := $tags }} + this.{{ camelcase $tag }} = new {{ pascalcase $tag }}Api(config); +{{- end }} + } +} + +export * from "./api/index.gen.js"; diff --git a/js/tools/tsplugingen/templates/models.ts.tmpl b/js/tools/tsplugingen/templates/models.ts.tmpl new file mode 100644 index 0000000..de546b4 --- /dev/null +++ b/js/tools/tsplugingen/templates/models.ts.tmpl @@ -0,0 +1,67 @@ +--- src/api/models.gen.ts +// Code generated by openapi-gen; DO NOT EDIT. +// +// Type definitions for the Unikraft Cloud `{{ .Var "pluginName" "plugin" }}` +// plugin API, generated from its OpenAPI specification. These mirror the wire +// format exactly. +{{ range $m := .Models }} +{{- $schema := $m.Schema }} +{{- $name := $m.SchemaName }} +{{- $openAPIType := getType $schema }} +{{- $parts := list }} +{{- if $schema.OneOf }}{{ $parts = append $parts "oneOf" }}{{ end }} +{{- if $schema.AnyOf }}{{ $parts = append $parts "anyOf" }}{{ end }} +{{- if $schema.AllOf }}{{ $parts = append $parts "allOf" }}{{ end }} +{{- if $schema.Properties }}{{ $parts = append $parts "properties" }}{{ end }} +{{- if gt (len $parts) 1 }} +{{- fail (printf "schema %s combines %s, which the templates cannot render" $name (join " and " $parts)) }} +{{- end }} +{{ with $schema.Description }}{{ tsDoc . "" }} +{{ end -}} +{{- if $schema.Enum }} +export type {{ $name }} = +{{- range $i, $v := $schema.Enum }} + | {{ enumTsValue $schema $v }} +{{- end }}; +{{- else if and $schema.OneOf (not $schema.Properties) }} +export type {{ $name }} = +{{- range $r := $schema.OneOf }} + | {{ tsTypeRef $r }} +{{- end }}; +{{- else if and $schema.AnyOf (not $schema.Properties) }} +export type {{ $name }} = +{{- range $r := $schema.AnyOf }} + | {{ tsTypeRef $r }} +{{- end }}; +{{- else if $schema.AllOf }} +export type {{ $name }} = +{{- range $r := $schema.AllOf }} +{{- $t := tsTypeRef $r }} + & {{ if contains " | " $t }}({{ $t }}){{ else }}{{ $t }}{{ end }} +{{- end }}; +{{- else if and (ne $openAPIType "object") (not $schema.Properties) }} +{{- /* A named schema of a non-object shape - an array of strings, say - is the + type it encodes to under a name of its own, not an empty interface. */}} +export type {{ $name }} = {{ schemaToTsType $schema }}; +{{- else }} +export interface {{ $name }} { +{{- range $prop := propertyNamesOrdered $name $schema }} +{{- $p := getProperty $schema $prop }} +{{- with $p.Description }} + {{ tsDoc . " " | trim }} +{{- end }} +{{- /* A property name is free-form in OpenAPI. toJson quotes and escapes it, + so a quote or a backslash in the name cannot break the literal. */}} + {{ toJson $prop }}{{ if not (getPropertyRequired $schema $prop) }}?{{ end }}: {{ schemaToTsType $p }}; +{{- end }} +{{- /* An interface names only its declared keys, so the index signature + carries the keys that the schema allows beyond them. Its value type is + unknown because TypeScript requires every declared property to be + assignable to it. Has is a *bool, and toJson reads the value behind it: + additionalProperties: false leaves a non-nil pointer to false. */}} +{{- if or $schema.AdditionalProperties.Schema (eq (toJson $schema.AdditionalProperties.Has) "true") }} + [key: string]: unknown; +{{- end }} +} +{{- end }} +{{ end }} diff --git a/js/tools/tsplugingen/templates/package.json.tmpl b/js/tools/tsplugingen/templates/package.json.tmpl new file mode 100644 index 0000000..69e38d9 --- /dev/null +++ b/js/tools/tsplugingen/templates/package.json.tmpl @@ -0,0 +1,58 @@ +{{- $plugin := .Var "pluginName" "plugin" -}} +{{- $version := .Var "version" "0.0.0" -}} +{{- $sdk := .Var "sdkVersion" "*" -}} +{{- $specHash := .Var "specHash" "" -}} +{{- $templatesHash := .Var "templatesHash" "" -}} +{{- $configHash := .Var "configHash" "" -}} +{{- if not (regexMatch "^[a-z0-9][a-z0-9._~-]*$" $plugin) -}} +{{- fail (printf "plugin name %q cannot be part of an npm package name; use lowercase letters, digits and - . _ ~" $plugin) -}} +{{- end -}} +--- package.json +{ + "name": "@unikraft/cloud-plugin-{{ $plugin }}-api", + "version": "{{ $version }}", + "description": "Generated TypeScript client for the Unikraft Cloud `{{ $plugin }}` plugin API", + "license": "BSD-3-Clause", + "author": "Unikraft GmbH", + "homepage": "https://unikraft.com", + "keywords": ["unikraft", "unikraft-cloud", "plugin", "{{ $plugin }}", "sdk"], + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "sideEffects": false, + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./models": { + "types": "./dist/api/models.gen.d.ts", + "default": "./dist/api/models.gen.js" + }, + "./package.json": "./package.json" + }, + "files": ["dist", "README.md"], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit", + "format": "biome format --write src" + }, + "peerDependencies": { + "@unikraft/cloud": "{{ $sdk }}" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.13", + "typescript": "^7.0.2" + }, + "publishConfig": { + "access": "public" + }, + "unikraft": { + "specHash": "{{ $specHash }}", + "templatesHash": "{{ $templatesHash }}", + "configHash": "{{ $configHash }}" + } +} diff --git a/js/tools/tsplugingen/templates/resources.tmpl b/js/tools/tsplugingen/templates/resources.tmpl new file mode 100644 index 0000000..18ee3c3 --- /dev/null +++ b/js/tools/tsplugingen/templates/resources.tmpl @@ -0,0 +1,237 @@ +{{- $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 -}} +{{- $names := list -}} +{{- range $tag := uniqueTags $ops -}} +{{- if not (regexMatch "^[A-Za-z_$][A-Za-z0-9_$]*$" (pascalcase $tag)) -}} +{{- fail (printf "tag %q cannot name a TypeScript class; rename it in the plugin's api.tsp" $tag) -}} +{{- end -}} +{{- if eq (camelcase $tag) "constructor" -}} +{{- fail (printf "tag %q gives a member named constructor, which TypeScript reserves for the class constructor; rename it in the plugin's api.tsp" $tag) -}} +{{- end -}} +{{- range $key := list (printf "class %s" (pascalcase $tag)) (printf "property %s" (camelcase $tag)) (printf "file %s" (kebabcase $tag)) -}} +{{- if has $key $names -}} +{{- fail (printf "tag %q gives the same %s as another tag; rename one of them in the plugin's api.tsp" $tag $key) -}} +{{- end -}} +{{- $names = append $names $key -}} +{{- end -}} +{{- $methods := list -}} +{{- 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) -}} +{{- 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) -}} +{{- end -}} +{{- $methods = append $methods $method -}} +{{- end -}} +{{- end -}} +{{- end -}} +{{- 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"; + +/** + * 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 { +{{- range $op := $ops }} +{{- if has $tag $op.Operation.Tags }} +{{ template "method" $op }} +{{- end }} +{{- end }} +} +{{ end }} +--- src/api/index.gen.ts +// Code generated by openapi-gen; DO NOT EDIT. +// +// Barrel for the generated "plumbing" API. +export type { ApiClient, CallOptions } from "{{ $clientImport }}"; +export * as models from "./models.gen.js"; +{{ range $tag := uniqueTags $ops }}export { {{ pascalcase $tag }}Api } from "./{{ kebabcase $tag }}.gen.js"; +{{ end }} +{{- define "method" -}} +{{- $po := . -}} +{{- $op := .Operation -}} +{{- $path := .Path -}} +{{- range pathParameters $po -}} +{{- if not .Schema -}} +{{- fail (printf "%s %s: path parameter %q has no schema, so the templates cannot serialize it" $po.Method $po.Path .Name) -}} +{{- else if has (getType .Schema.Value) (list "object" "array") -}} +{{- 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. */ -}} +{{- 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. */ -}} +{{- $success := list -}} +{{- range $entry := sortedResponseCodes $op.Responses -}} +{{- if hasPrefix "2" $entry.Code -}}{{- $success = append $success $entry -}}{{- end -}} +{{- end -}} +{{- if not $success -}} +{{- range $entry := sortedResponseCodes $op.Responses -}} +{{- if eq $entry.Code "default" -}}{{- $success = append $success $entry -}}{{- end -}} +{{- end -}} +{{- end -}} +{{- /* A method makes one ApiClient 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" -}} +{{- $rets := list -}} +{{- $kinds := list -}} +{{- range $entry := $success -}} +{{- with $entry.Ref -}}{{- with .Value -}}{{- if .Content -}} +{{- $handled := false -}} +{{- with index .Content "application/json" -}} +{{- $handled = true -}}{{- $kinds = append $kinds "application/json" -}} +{{- $type := "unknown" -}}{{- with .Schema -}}{{- $type = qualifyModels "models" (tsTypeRef .) -}}{{- end -}} +{{- $rets = append $rets $type -}} +{{- end -}} +{{- with index .Content "text/event-stream" -}} +{{- $handled = true -}}{{- $kinds = append $kinds "text/event-stream" -}} +{{- $type := "unknown" -}}{{- with .Schema -}}{{- $type = qualifyModels "models" (tsTypeRef .) -}}{{- end -}} +{{- $rets = append $rets $type -}} +{{- end -}} +{{- with index .Content "application/octet-stream" -}} +{{- $handled = true -}}{{- $kinds = append $kinds "application/octet-stream" -}}{{- $rets = append $rets "Uint8Array" -}} +{{- end -}} +{{- if not $handled -}} +{{- fail (printf "%s %s: the %s response has no media type that the templates can return" $po.Method $po.Path $entry.Code) -}} +{{- end -}} +{{- end -}}{{- end -}}{{- end -}} +{{- end -}} +{{- $kinds = uniq $kinds -}} +{{- if gt (len $kinds) 1 -}} +{{- fail (printf "%s %s: the success responses use %s, but a generated method decodes one media type only" $po.Method $po.Path (join " and " $kinds)) -}} +{{- end -}} +{{- with uniq $rets -}}{{- $ret = join " | " . -}}{{- end -}} +{{- $sse := has "text/event-stream" $kinds -}} +{{- $oct := has "application/octet-stream" $kinds -}} +{{- $bodyRef := requestJSONSchema $op -}} +{{- $bodyReq := requestBodyRequired $op -}} +{{- if and $op.RequestBody (not $bodyRef) -}} +{{- fail (printf "%s %s: the request body is not application/json, and the templates send JSON only" $po.Method $po.Path) -}} +{{- end -}} +{{- $query := queryParameters $po -}} +{{- /* An operation parameter overrides a path-item parameter with the same + name and location. */ -}} +{{- $header := list -}} +{{- $headerNames := list -}} +{{- range $op.Parameters -}}{{- with .Value -}} +{{- if eq .In "header" -}} +{{- $header = append $header . -}}{{- $headerNames = append $headerNames .Name -}} +{{- end -}} +{{- end -}}{{- end -}} +{{- with $po.PathItem -}} +{{- range .Parameters -}}{{- with .Value -}} +{{- if and (eq .In "header") (not (has .Name $headerNames)) -}} +{{- $header = append $header . -}} +{{- end -}} +{{- end -}}{{- end -}} +{{- end -}} +{{- /* Browsers forbid a Cookie header in fetch, and ApiClient 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) -}} +{{- end -}}{{- end -}}{{- end -}} +{{- with $po.PathItem -}}{{- range .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) -}} +{{- end -}}{{- end -}}{{- end -}}{{- end -}} +{{- /* fetch cannot send a body on GET or HEAD, so an optional body is + dropped. */ -}} +{{- if or (eq .Method "GET") (eq .Method "HEAD") -}} +{{- if $bodyReq -}} +{{- fail (printf "%s %s requires a request body, which fetch cannot send on %s" .Method .Path .Method) -}} +{{- end -}} +{{- $bodyRef = false -}} +{{- $bodyReq = false -}} +{{- end -}} +{{- /* The method passes `params` to ApiClient, 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 -}} +{{- fail (printf "%s %s: query parameter %q has the name of a call option or of the body; rename it in the plugin's api.tsp" $po.Method $po.Path .Name) -}} +{{- end -}}{{- end -}} +{{- range $header -}} +{{- if has .Name $reserved -}} +{{- fail (printf "%s %s: header parameter %q has the name of a call option or of the body; rename it in the plugin's api.tsp" $po.Method $po.Path .Name) -}} +{{- end -}} +{{- if not .Schema -}} +{{- fail (printf "%s %s: header parameter %q has no schema, so the templates cannot serialize it" $po.Method $po.Path .Name) -}} +{{- else if or (eq (getType .Schema.Value) "object") (and .Schema.Value.Items (has (getType .Schema.Value.Items.Value) (list "object" "array"))) -}} +{{- fail (printf "%s %s: header parameter %q is not a scalar or an array of scalars, so String() cannot serialize it" $po.Method $po.Path .Name) -}} +{{- end -}} +{{- end -}} +{{- range $q := $query -}}{{- range $h := $header -}} +{{- if eq $q.Name $h.Name -}} +{{- fail (printf "%s %s: %q names both a query and a header parameter, and one params object holds one of them; rename one in the plugin's api.tsp" $po.Method $po.Path $q.Name) -}} +{{- end -}}{{- end -}}{{- end -}} +{{- /* A required member under a `= {}` default does not typecheck. */ -}} +{{- $paramsReq := $bodyReq -}} +{{- range $query }}{{- if .Required }}{{- $paramsReq = true -}}{{- end -}}{{- end -}} +{{- range $header }}{{- if .Required }}{{- $paramsReq = true -}}{{- end -}}{{- end }} +{{ with $op.Summary }}{{ tsDoc . " " }} +{{ else }}{{ with $op.Description }}{{ tsDoc . " " }} +{{ end }}{{ end }} {{ tsSafeName (camelcase $op.OperationID) }}( + {{- range pathParameters $po }} + {{ tsSafeName .Name }}: {{ qualifyModels "models" (paramToTsType .) }}, + {{- end }} + {{- if or $query $header $bodyRef }} + params: { + {{- /* A parameter name is free-form in OpenAPI. toJson quotes and + escapes it, so the name cannot break the literal. */}} + {{- range $query }} + {{ toJson .Name }}{{ if not .Required }}?{{ end }}: {{ qualifyModels "models" (paramToTsType .) }}; + {{- end }} + {{- range $header }} + {{ toJson .Name }}{{ if not .Required }}?{{ end }}: {{ qualifyModels "models" (paramToTsType .) }}; + {{- end }} + {{- if $bodyRef }} + body{{ if not $bodyReq }}?{{ end }}: {{ qualifyModels "models" (tsTypeRef $bodyRef) }}; + {{- end }} + } & CallOptions{{ if not $paramsReq }} = {}{{ end }}, + {{- else }} + 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 }}( + { + method: "{{ .Method }}", + path: `{{ $path }}`, + {{- if $query }} + query: { + {{- range $query }} + {{ toJson .Name }}: params[{{ toJson .Name }}], + {{- end }} + }, + {{- end }} + {{- if $bodyRef }} + body: params.body, + {{- end }} + }, + {{- if $header }} + { + ...params, + headers: { + {{- range $header }} + ...(params[{{ toJson .Name }}] !== undefined ? { {{ toJson .Name }}: String(params[{{ toJson .Name }}]) } : {}), + {{- end }} + ...params.headers, + }, + }, + {{- else }} + params, + {{- end }} + ); + } +{{- end -}}