Commit ed77ee9e by PLN (Algolia)

feat(audio-api): @nech/api — fully-generated TS client + Verdaccio runbook (#44)

The hand-written nech.ts was always interim; this replaces it with a client
GENERATED from the OpenAPI spec, so the SDK can never drift from the API.

Source-of-truth fixes (the spec drives the ergonomics):
- Clean operationIds on all 13 routes (operation_id="analyzeEmotion" etc.) so
  the generator emits `audio.analyzeEmotion({file})`, not the default
  `analyzeEmotionAnalyzeEmotionPost`. Also cleans /docs.
- One router tag ["audio"] so the generated class is AudioApi, not DefaultApi.
- refresh_openapi.py: the canonical snapshot dump. app.openapi() omits the
  public `servers` block (only injected when served behind root_path), and the
  old README recipe silently dropped it — test_openapi_snapshot guards it, so
  the refresh now injects https://api.nech.pl/audio/v1 itself.

The package (@nech/api, clients/nech-api/):
- typescript-fetch generator → src/audio/ (checked in; regenerate via
  codegen.sh), bundled with tsup to a single ESM file + .d.ts.
- Umbrella package, per-domain SUBPATH exports: `import {AudioApi} from
  '@nech/api/audio'` — one install/version, tree-shakeable, geo/iris slot in as
  siblings later (codegen.sh + exports map have the stubs).
- Build via tsup not bare tsc: the generated code uses extensionless relative
  imports (./runtime) that Node ESM can't resolve from plain tsc output;
  bundling sidesteps it entirely. Verified end-to-end: `@nech/api/audio`
  resolves through the exports map, 13/13 methods present.
- publishConfig + .npmrc point the @nech scope at https://npm.nech.pl.

VERDACCIO.md: copy-paste runbook to stand the private registry up on erable
(container :4873, nginx TLS vhost for npm.nech.pl, seed publisher + lock
signups, publish). NOT yet run — `ssh erable` failed with publickey from the
build host; needs the key loaded. DNS for npm.nech.pl is already set.

README reframed: @nech/api is the official client; nech.ts stays documented as
the working drop-in until the registry is live, then it's retired. Scrubbed a
$DOUANIER_TOKEN codename leak in the curl example. 67 API tests still green.
parent 12e03903
# npm.nech.pl — private npm registry (Verdaccio) on erable
Hosts the `@nech/*` packages (the generated API clients, `@nech/api`). Public
deps proxy through to npmjs, so a project using `@nech/api` still resolves
`react` etc. normally. Same shape as `api.nech.pl`: a loopback container behind
the nginx TLS edge.
DNS `npm.nech.pl` → erable is set. erable = `pln@nech.pl`, Docker, nginx in
`/etc/nginx/sites-enabled/`, passwordless sudo. Run as `pln`.
> **Status:** runbook ready, NOT yet deployed — needs an ssh session to erable
> (`ssh erable` failed with `Permission denied (publickey)` from the build host;
> load the key first). Everything below is copy-paste once you're on erable.
## 1. Verdaccio container (loopback :4873)
```bash
mkdir -p ~/srv/verdaccio/{storage,conf}
# config — auth required to publish, @nech is local, everything else proxies npmjs
cat > ~/srv/verdaccio/conf/config.yaml <<'YAML'
storage: /verdaccio/storage
auth:
htpasswd:
file: /verdaccio/storage/htpasswd
max_users: -1 # -1 after seeding = no open self-registration
uplinks:
npmjs:
url: https://registry.npmjs.org/
packages:
'@nech/*':
access: $authenticated
publish: $authenticated
unpublish: $authenticated
'**':
access: $authenticated
publish: $authenticated
proxy: npmjs
server:
keepAliveTimeout: 60
log: { type: stdout, format: pretty, level: info }
YAML
docker run -d --name verdaccio --restart unless-stopped \
-p 127.0.0.1:4873:4873 \
-v ~/srv/verdaccio/storage:/verdaccio/storage \
-v ~/srv/verdaccio/conf:/verdaccio/conf \
verdaccio/verdaccio:6
```
(If Docker is the old 19.03 that needs it for other images, Verdaccio's node
image is fine without `seccomp=unconfined` — it's not the glibc/clone3 case.)
## 2. nginx vhost + TLS
```bash
sudo certbot certonly --webroot -w /var/www/challenges -d npm.nech.pl
sudo tee /etc/nginx/sites-available/npm.nech.pl.conf >/dev/null <<'NGINX'
server {
listen 80; listen [::]:80;
server_name npm.nech.pl;
include includes/acme-challenge;
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2; listen [::]:443 ssl http2;
server_name npm.nech.pl;
ssl_certificate /etc/letsencrypt/live/npm.nech.pl/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/npm.nech.pl/privkey.pem;
ssl_protocols TLSv1.2;
client_max_body_size 50m; # tarballs
location / {
proxy_pass http://127.0.0.1:4873;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}
}
NGINX
sudo ln -sf /etc/nginx/sites-available/npm.nech.pl.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
## 3. Seed the publisher user, then lock signups
```bash
npm adduser --registry https://npm.nech.pl # create pln
# then set max_users: -1 (already above) and restart so no one else can self-register
docker restart verdaccio
```
## 4. Publish @nech/api
```bash
cd armada/api/clients/nech-api
npm login --registry https://npm.nech.pl
npm publish # prepublishOnly runs the build
```
Consumers (hexa): `echo '@nech:registry=https://npm.nech.pl' >> .npmrc && npm i @nech/api`.
...@@ -44,7 +44,8 @@ app.add_middleware( ...@@ -44,7 +44,8 @@ app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
) )
v1 = APIRouter() # routes mounted at root; the gateway owns the /audio/v1 prefix. v1 = APIRouter(tags=["audio"]) # routes mounted at root; the gateway owns the /audio/v1 prefix.
# tag => generated client class is AudioApi (not DefaultApi).
# ── observability (#37) — a counter the erable Prometheus scrapes ─────────────── # ── observability (#37) — a counter the erable Prometheus scrapes ───────────────
_REQ_COUNTS: dict[tuple[str, int], int] = {} _REQ_COUNTS: dict[tuple[str, int], int] = {}
...@@ -113,7 +114,7 @@ def require_auth(request: Request) -> auth.Principal: ...@@ -113,7 +114,7 @@ def require_auth(request: Request) -> auth.Principal:
# ── health ────────────────────────────────────────────────────────────────── # ── health ──────────────────────────────────────────────────────────────────
@v1.get("/healthz") @v1.get("/healthz", operation_id="healthz")
def healthz(): def healthz():
ok, hint = ears.available() ok, hint = ears.available()
return { return {
...@@ -168,7 +169,7 @@ def metrics(): ...@@ -168,7 +169,7 @@ def metrics():
return Response("\n".join(lines) + "\n", media_type="text/plain; version=0.0.4") return Response("\n".join(lines) + "\n", media_type="text/plain; version=0.0.4")
@v1.get("/me") @v1.get("/me", operation_id="whoami")
def whoami(principal: auth.Principal = Depends(require_auth)): def whoami(principal: auth.Principal = Depends(require_auth)):
"""Echo the caller's gateway-resolved identity — handy for onboarding smoke tests.""" """Echo the caller's gateway-resolved identity — handy for onboarding smoke tests."""
return {"account": principal.account, "scopes": sorted(principal.scopes), return {"account": principal.account, "scopes": sorted(principal.scopes),
...@@ -206,7 +207,7 @@ def _cached(kind: str, params: dict, data: bytes, filename: str, compute, respon ...@@ -206,7 +207,7 @@ def _cached(kind: str, params: dict, data: bytes, filename: str, compute, respon
return cid, result return cid, result
@v1.post("/analyze/emotion") @v1.post("/analyze/emotion", operation_id="analyzeEmotion")
async def analyze_emotion(response: Response, file: UploadFile = File(...), async def analyze_emotion(response: Response, file: UploadFile = File(...),
_p: auth.Principal = Depends(require)): _p: auth.Principal = Depends(require)):
"""Upload a short audio clip → valence/arousal + top emotions. Cached (#26): """Upload a short audio clip → valence/arousal + top emotions. Cached (#26):
...@@ -217,7 +218,7 @@ async def analyze_emotion(response: Response, file: UploadFile = File(...), ...@@ -217,7 +218,7 @@ async def analyze_emotion(response: Response, file: UploadFile = File(...),
return {"filename": file.filename, "content_id": cid, "emotion": result} return {"filename": file.filename, "content_id": cid, "emotion": result}
@v1.post("/features") @v1.post("/features", operation_id="features")
async def features(response: Response, file: UploadFile = File(...), rhythm: bool = True, async def features(response: Response, file: UploadFile = File(...), rhythm: bool = True,
_p: auth.Principal = Depends(require)): _p: auth.Principal = Depends(require)):
"""Upload a clip → the ~35-dim audio feature stack (spectral moments, MFCCs, """Upload a clip → the ~35-dim audio feature stack (spectral moments, MFCCs,
...@@ -229,7 +230,7 @@ async def features(response: Response, file: UploadFile = File(...), rhythm: boo ...@@ -229,7 +230,7 @@ async def features(response: Response, file: UploadFile = File(...), rhythm: boo
return {"filename": file.filename, "content_id": cid, **result} return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/analyze/samples") @v1.post("/analyze/samples", operation_id="analyzeSamples")
async def analyze_samples(response: Response, file: UploadFile = File(...), name: str = "", async def analyze_samples(response: Response, file: UploadFile = File(...), name: str = "",
_p: auth.Principal = Depends(require)): _p: auth.Principal = Depends(require)):
"""Upload a one-shot/loop → per-sample EDA + MEASURED role (percs|bass|melodic| """Upload a one-shot/loop → per-sample EDA + MEASURED role (percs|bass|melodic|
...@@ -242,7 +243,7 @@ async def analyze_samples(response: Response, file: UploadFile = File(...), name ...@@ -242,7 +243,7 @@ async def analyze_samples(response: Response, file: UploadFile = File(...), name
return {"filename": file.filename, "content_id": cid, **result} return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/onsets") @v1.post("/onsets", operation_id="onsets")
async def onsets(response: Response, file: UploadFile = File(...), async def onsets(response: Response, file: UploadFile = File(...),
_p: auth.Principal = Depends(require)): _p: auth.Principal = Depends(require)):
"""Upload audio → onset hit times (s) + tempo + onset rate. The cheapest tier """Upload audio → onset hit times (s) + tempo + onset rate. The cheapest tier
...@@ -252,7 +253,7 @@ async def onsets(response: Response, file: UploadFile = File(...), ...@@ -252,7 +253,7 @@ async def onsets(response: Response, file: UploadFile = File(...),
return {"filename": file.filename, "content_id": cid, **result} return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/waveform") @v1.post("/waveform", operation_id="waveform")
async def waveform(response: Response, file: UploadFile = File(...), bins: int = 800, async def waveform(response: Response, file: UploadFile = File(...), bins: int = 800,
_p: auth.Principal = Depends(require)): _p: auth.Principal = Depends(require)):
"""Upload audio → render-ready waveform: per-bin [min,max] + a 0..1 RMS energy """Upload audio → render-ready waveform: per-bin [min,max] + a 0..1 RMS energy
...@@ -263,7 +264,7 @@ async def waveform(response: Response, file: UploadFile = File(...), bins: int = ...@@ -263,7 +264,7 @@ async def waveform(response: Response, file: UploadFile = File(...), bins: int =
return {"filename": file.filename, "content_id": cid, **result} return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/analyze") @v1.post("/analyze", operation_id="analyzeAll")
async def analyze_all(response: Response, file: UploadFile = File(...), async def analyze_all(response: Response, file: UploadFile = File(...),
_p: auth.Principal = Depends(require)): _p: auth.Principal = Depends(require)):
"""One call → emotion + features + sample role for a clip (fewer round-trips """One call → emotion + features + sample role for a clip (fewer round-trips
...@@ -279,7 +280,7 @@ async def analyze_all(response: Response, file: UploadFile = File(...), ...@@ -279,7 +280,7 @@ async def analyze_all(response: Response, file: UploadFile = File(...),
return {"filename": file.filename, "content_id": cid, **result} return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/grade") @v1.post("/grade", operation_id="grade")
async def grade(response: Response, file: UploadFile = File(...), role: str = "", async def grade(response: Response, file: UploadFile = File(...), role: str = "",
_p: auth.Principal = Depends(require)): _p: auth.Principal = Depends(require)):
"""Upload a loop/one-shot → the Foundry's MECHANICAL quality grade: composite """Upload a loop/one-shot → the Foundry's MECHANICAL quality grade: composite
...@@ -293,7 +294,7 @@ async def grade(response: Response, file: UploadFile = File(...), role: str = "" ...@@ -293,7 +294,7 @@ async def grade(response: Response, file: UploadFile = File(...), role: str = ""
return {"filename": file.filename, "content_id": cid, "grade": result} return {"filename": file.filename, "content_id": cid, "grade": result}
@v1.post("/loudness") @v1.post("/loudness", operation_id="loudness")
async def loudness(response: Response, file: UploadFile = File(...), async def loudness(response: Response, file: UploadFile = File(...),
_p: auth.Principal = Depends(require)): _p: auth.Principal = Depends(require)):
"""Upload audio → BS.1770 integrated LUFS + sample/true peak + crest + the """Upload audio → BS.1770 integrated LUFS + sample/true peak + crest + the
...@@ -304,7 +305,7 @@ async def loudness(response: Response, file: UploadFile = File(...), ...@@ -304,7 +305,7 @@ async def loudness(response: Response, file: UploadFile = File(...),
return {"filename": file.filename, "content_id": cid, **result} return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/spectrum") @v1.post("/spectrum", operation_id="spectrum")
async def spectrum(response: Response, file: UploadFile = File(...), async def spectrum(response: Response, file: UploadFile = File(...),
bands: int = 64, frames: int = 200, mel: bool = True, bands: int = 64, frames: int = 200, mel: bool = True,
_p: auth.Principal = Depends(require)): _p: auth.Principal = Depends(require)):
...@@ -319,7 +320,7 @@ async def spectrum(response: Response, file: UploadFile = File(...), ...@@ -319,7 +320,7 @@ async def spectrum(response: Response, file: UploadFile = File(...),
return {"filename": file.filename, "content_id": cid, **result} return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/naming") @v1.post("/naming", operation_id="naming")
async def naming(response: Response, file: UploadFile = File(...), index: int = 1, async def naming(response: Response, file: UploadFile = File(...), index: int = 1,
_p: auth.Principal = Depends(require)): _p: auth.Principal = Depends(require)):
"""Upload a sample → a convention-compliant name (NN_role_character) derived """Upload a sample → a convention-compliant name (NN_role_character) derived
...@@ -338,7 +339,7 @@ async def naming(response: Response, file: UploadFile = File(...), index: int = ...@@ -338,7 +339,7 @@ async def naming(response: Response, file: UploadFile = File(...), index: int =
# ── #37 separation seam — no CPU path on erable; env-selected GPU backend later ── # ── #37 separation seam — no CPU path on erable; env-selected GPU backend later ──
@v1.post("/separate") @v1.post("/separate", operation_id="separate")
async def separate(_p: auth.Principal = Depends(require)): async def separate(_p: auth.Principal = Depends(require)):
"""Stem separation (demucs/roformer). erable has no GPU and no CPU-viable path, """Stem separation (demucs/roformer). erable has no GPU and no CPU-viable path,
so this is a deliberate 503 until a GPU-runner backend is wired (env-selected, so this is a deliberate 503 until a GPU-runner backend is wired (env-selected,
......
...@@ -9,48 +9,52 @@ Every analysis is a `multipart` POST with one `file` field (an audio clip) and ...@@ -9,48 +9,52 @@ Every analysis is a `multipart` POST with one `file` field (an audio clip) and
returns JSON. Identical audio is **cached** — the `X-Nech-Cache: hit|miss` returns JSON. Identical audio is **cached** — the `X-Nech-Cache: hit|miss`
header (and `result._cache` in the TS client) tells you which. header (and `result._cache` in the TS client) tells you which.
## TypeScript client (zero deps) ## Official TypeScript client — `@nech/api` (generated)
Drop [`nech.ts`](./nech.ts) into your project (Node 18+, Vercel Functions, or The official client is **generated** from this spec, so it can never drift from
Edge — it only uses global `fetch`/`FormData`/`Blob`). One umbrella client, the API. One package, per-domain subpath imports (you only bundle what you use).
namespaced per sub-API (`nech.audio.…`, and `nech.geo.…` when it ships): Source + build: [`nech-api/`](./nech-api/).
```ts ```bash
import { NechAPI } from "./nech"; echo '@nech:registry=https://npm.nech.pl' >> .npmrc # the @nech scope is private
npm i @nech/api
const nech = new NechAPI({ token: process.env.NECHPL_TOKEN! });
// a clip from anywhere → a Blob
const clip = await fetch(trackUrl).then(r => r.blob());
const mood = await nech.audio.emotion(clip); // { emotion: { valence, arousal, top, … } }
const spec = await nech.audio.spectrum(clip, { bands: 64, frames: 200 }); // FFT-as-a-service
const wave = await nech.audio.waveform(clip, { bins: 800 }); // render-ready envelope
const loud = await nech.audio.loudness(clip); // { lufs_integrated, gain_to_target_db, … }
if (mood._cache === "hit") { /* came free from the cache */ }
``` ```
Want a smaller bundle? Import just the sub-client:
```ts ```ts
import { NechAudio } from "./nech"; import { AudioApi, Configuration } from "@nech/api/audio";
const audio = new NechAudio({ token: process.env.NECHPL_TOKEN! });
const mood = await audio.emotion(clip); const audio = new AudioApi(new Configuration({
basePath: "https://api.nech.pl/audio/v1",
accessToken: process.env.NECH_TOKEN,
}));
const clip = await fetch(trackUrl).then(r => r.blob()); // Blob/File
const mood = await audio.analyzeEmotion({ file: clip }); // method = operationId
const loud = await audio.loudness({ file: clip });
``` ```
### Vercel Function example ### Vercel Function example
```ts ```ts
// app/api/mood/route.ts (Next.js App Router, Node runtime) // app/api/mood/route.ts (Next.js App Router, Node runtime)
import { NechAPI } from "@/lib/nech"; import { AudioApi, Configuration } from "@nech/api/audio";
export async function POST(req: Request) { export async function POST(req: Request) {
const clip = await req.blob(); const clip = await req.blob();
const nech = new NechAPI({ token: process.env.NECHPL_TOKEN! }); const audio = new AudioApi(new Configuration({
const { emotion } = await nech.audio.emotion(clip); basePath: "https://api.nech.pl/audio/v1",
return Response.json(emotion); accessToken: process.env.NECH_TOKEN,
}));
const res = await audio.analyzeEmotion({ file: clip });
return Response.json(res);
} }
``` ```
> **Interim:** until Verdaccio (`npm.nech.pl`) is deployed (see
> [`../VERDACCIO.md`](../VERDACCIO.md)), the zero-dep drop-in
> [`nech.ts`](./nech.ts) — `new NechAPI({token}).audio.emotion(clip)` — is the
> working client. It's superseded by `@nech/api` once the registry is live.
## Scopes ## Scopes
Access is by the platform's hierarchical scope convention. The scope a route Access is by the platform's hierarchical scope convention. The scope a route
...@@ -82,18 +86,26 @@ convention: the platform's `RECIPE.md` / `scopes.py`.) ...@@ -82,18 +86,26 @@ convention: the platform's `RECIPE.md` / `scopes.py`.)
## curl ## curl
```bash ```bash
curl -s -H "Authorization: Bearer $DOUANIER_TOKEN" \ curl -s -H "Authorization: Bearer $NECH_TOKEN" \
-F file=@clip.wav https://api.nech.pl/audio/v1/loudness | jq -F file=@clip.wav https://api.nech.pl/audio/v1/loudness | jq
``` ```
## Regenerating the snapshot / a full client ## Regenerating the spec + client
`openapi.json` here is a checked-in snapshot. To codegen a client in any language: `openapi.json` is a checked-in snapshot of the live app; `@nech/api` is generated
from it. Both are reproducible:
```bash ```bash
npx @openapitools/openapi-generator-cli generate \ # 1. refresh the snapshot from the app (adds the public server block)
-i armada/api/clients/openapi.json -g typescript-fetch -o ./generated ~/.virtualenvs/douanier/bin/python clients/refresh_openapi.py
# 2. regenerate + bundle the TS client
cd clients/nech-api && ./codegen.sh
``` ```
The hand-written `nech.ts` is the lean, dependency-free alternative — prefer `test_openapi_snapshot.py` guards the snapshot against route drift. For an
it unless you specifically want generated models. ad-hoc client in another language, point any generator at `openapi.json`:
```bash
npx @openapitools/openapi-generator-cli generate \
-i armada/api/clients/openapi.json -g python -o ./generated # e.g. Python (nech_api, later)
```
# The @nech scope lives on the private registry (Verdaccio on erable).
# Consumers need this line too (in their project .npmrc or ~/.npmrc):
@nech:registry=https://npm.nech.pl
# @nech/api
Generated TypeScript clients for the [Nech.PL APIs](https://api.nech.pl). One
package, **per-domain subpath imports** — you only bundle what you import.
```bash
# one-time: point the @nech scope at the private registry
echo '@nech:registry=https://npm.nech.pl' >> .npmrc
npm i @nech/api
```
## Usage
```ts
import { AudioApi, Configuration } from '@nech/api/audio'
const audio = new AudioApi(new Configuration({
basePath: 'https://api.nech.pl/audio/v1',
accessToken: process.env.NECH_TOKEN, // your platform token (npl_live_…)
}))
// every analysis is a multipart POST of one audio clip → JSON
const emotion = await audio.analyzeEmotion({ file: clip }) // clip: Blob | File
const grade = await audio.grade({ file: clip })
```
Method names come straight from the API's `operationId`s, so the call site
tracks the spec. `clip` is a `Blob`/`File` (browser) or a `Blob` from
`fs.openAsBlob()` (Node ≥20).
Future domains land as sibling subpaths: `@nech/api/geo`, `@nech/api/iris`.
## This package is generated — don't hand-edit `src/`
Everything under `src/<domain>/` is emitted by
[openapi-generator](https://openapi-generator.tech) (`typescript-fetch`) from the
checked-in OpenAPI snapshot, then bundled with tsup.
```bash
# 1. refresh the snapshot from the live app (run from ../, in the venv)
~/.virtualenvs/douanier/bin/python ../refresh_openapi.py
# 2. regenerate + bundle every domain
./codegen.sh
```
`dist/` is built on publish (`prepublishOnly`); it's gitignored.
## Publishing (private registry)
Published to **Verdaccio at `https://npm.nech.pl`** (see
`../../VERDACCIO.md` for the erable deploy).
```bash
npm login --registry https://npm.nech.pl # one-time
npm publish # runs the build first
```
#!/usr/bin/env bash
# Regenerate @nech/api from the checked-in OpenAPI snapshot(s), then bundle.
# Fully generated — no hand-written client code to drift. Re-run after any API
# change (first refresh the snapshot: ../refresh_openapi.py).
#
# ./codegen.sh
#
# Adding a domain (geo, iris): vendor its openapi.json next to ../openapi.json
# and add a generate block below + an exports entry in package.json.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
GEN="npx --yes @openapitools/openapi-generator-cli generate"
PROPS="supportsES6=true,typescriptThreePlus=true"
gen() { # gen <domain> <spec-path>
local domain="$1" spec="$2"
echo "→ generating $domain from $spec"
$GEN -i "$spec" -g typescript-fetch -o "$HERE/src/$domain" \
--additional-properties="$PROPS"
rm -rf "$HERE/src/$domain/docs" "$HERE/src/$domain/.openapi-generator" \
"$HERE/src/$domain/.openapi-generator-ignore"
}
gen audio "$HERE/../openapi.json"
# gen geo "$HERE/../openapi.geo.json" # when geo's spec is vendored
# gen iris "$HERE/../openapi.iris.json" # when iris's spec is vendored
cd "$HERE"
npm run build
echo "✓ @nech/api regenerated + bundled"
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.23.0"
}
}
{
"name": "@nech/api",
"version": "0.1.0",
"description": "Generated TypeScript clients for the Nech.PL APIs (api.nech.pl). One package, per-domain subpath imports.",
"type": "module",
"license": "UNLICENSED",
"private": false,
"publishConfig": {
"registry": "https://npm.nech.pl"
},
"exports": {
"./audio": {
"types": "./dist/audio/index.d.ts",
"import": "./dist/audio/index.js",
"default": "./dist/audio/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup src/audio/index.ts --format esm --dts --clean --out-dir dist/audio",
"prepublishOnly": "npm run build"
},
"engines": {
"node": ">=18"
}
}
/* tslint:disable */
/* eslint-disable */
export * from './AudioApi';
/* tslint:disable */
/* eslint-disable */
export * from './runtime';
export * from './apis/index';
export * from './models/index';
/* tslint:disable */
/* eslint-disable */
/**
* Nech.PL Audio Intelligence API
* Stem-aware audio analysis — emotion, features, sample role, loop grading, onsets/tempo, waveform & spectrogram, loudness, naming. The `audio` API of the Nech.PL APIs platform, served at /audio/v1/...; the gateway does central auth + metering (Bearer token).
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { mapValues } from '../runtime';
import type { ValidationError } from './ValidationError';
import {
ValidationErrorFromJSON,
ValidationErrorFromJSONTyped,
ValidationErrorToJSON,
ValidationErrorToJSONTyped,
} from './ValidationError';
/**
*
* @export
* @interface HTTPValidationError
*/
export interface HTTPValidationError {
/**
*
* @type {Array<ValidationError>}
* @memberof HTTPValidationError
*/
detail?: Array<ValidationError>;
}
/**
* Check if a given object implements the HTTPValidationError interface.
*/
export function instanceOfHTTPValidationError(value: object): value is HTTPValidationError {
return true;
}
export function HTTPValidationErrorFromJSON(json: any): HTTPValidationError {
return HTTPValidationErrorFromJSONTyped(json, false);
}
export function HTTPValidationErrorFromJSONTyped(json: any, ignoreDiscriminator: boolean): HTTPValidationError {
if (json == null) {
return json;
}
return {
'detail': json['detail'] == null ? undefined : ((json['detail'] as Array<any>).map(ValidationErrorFromJSON)),
};
}
export function HTTPValidationErrorToJSON(json: any): HTTPValidationError {
return HTTPValidationErrorToJSONTyped(json, false);
}
export function HTTPValidationErrorToJSONTyped(value?: HTTPValidationError | null, ignoreDiscriminator: boolean = false): any {
if (value == null) {
return value;
}
return {
'detail': value['detail'] == null ? undefined : ((value['detail'] as Array<any>).map(ValidationErrorToJSON)),
};
}
/* tslint:disable */
/* eslint-disable */
/**
* Nech.PL Audio Intelligence API
* Stem-aware audio analysis — emotion, features, sample role, loop grading, onsets/tempo, waveform & spectrogram, loudness, naming. The `audio` API of the Nech.PL APIs platform, served at /audio/v1/...; the gateway does central auth + metering (Bearer token).
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { mapValues } from '../runtime';
/**
*
* @export
* @interface LocationInner
*/
export interface LocationInner {
}
/**
* Check if a given object implements the LocationInner interface.
*/
export function instanceOfLocationInner(value: object): value is LocationInner {
return true;
}
export function LocationInnerFromJSON(json: any): LocationInner {
return LocationInnerFromJSONTyped(json, false);
}
export function LocationInnerFromJSONTyped(json: any, ignoreDiscriminator: boolean): LocationInner {
return json;
}
export function LocationInnerToJSON(json: any): LocationInner {
return LocationInnerToJSONTyped(json, false);
}
export function LocationInnerToJSONTyped(value?: LocationInner | null, ignoreDiscriminator: boolean = false): any {
return value;
}
/* tslint:disable */
/* eslint-disable */
/**
* Nech.PL Audio Intelligence API
* Stem-aware audio analysis — emotion, features, sample role, loop grading, onsets/tempo, waveform & spectrogram, loudness, naming. The `audio` API of the Nech.PL APIs platform, served at /audio/v1/...; the gateway does central auth + metering (Bearer token).
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { mapValues } from '../runtime';
import type { LocationInner } from './LocationInner';
import {
LocationInnerFromJSON,
LocationInnerFromJSONTyped,
LocationInnerToJSON,
LocationInnerToJSONTyped,
} from './LocationInner';
/**
*
* @export
* @interface ValidationError
*/
export interface ValidationError {
/**
*
* @type {Array<LocationInner>}
* @memberof ValidationError
*/
loc: Array<LocationInner>;
/**
*
* @type {string}
* @memberof ValidationError
*/
msg: string;
/**
*
* @type {string}
* @memberof ValidationError
*/
type: string;
/**
*
* @type {any}
* @memberof ValidationError
*/
input?: any | null;
/**
*
* @type {object}
* @memberof ValidationError
*/
ctx?: object;
}
/**
* Check if a given object implements the ValidationError interface.
*/
export function instanceOfValidationError(value: object): value is ValidationError {
if (!('loc' in value) || value['loc'] === undefined) return false;
if (!('msg' in value) || value['msg'] === undefined) return false;
if (!('type' in value) || value['type'] === undefined) return false;
return true;
}
export function ValidationErrorFromJSON(json: any): ValidationError {
return ValidationErrorFromJSONTyped(json, false);
}
export function ValidationErrorFromJSONTyped(json: any, ignoreDiscriminator: boolean): ValidationError {
if (json == null) {
return json;
}
return {
'loc': ((json['loc'] as Array<any>).map(LocationInnerFromJSON)),
'msg': json['msg'],
'type': json['type'],
'input': json['input'] == null ? undefined : json['input'],
'ctx': json['ctx'] == null ? undefined : json['ctx'],
};
}
export function ValidationErrorToJSON(json: any): ValidationError {
return ValidationErrorToJSONTyped(json, false);
}
export function ValidationErrorToJSONTyped(value?: ValidationError | null, ignoreDiscriminator: boolean = false): any {
if (value == null) {
return value;
}
return {
'loc': ((value['loc'] as Array<any>).map(LocationInnerToJSON)),
'msg': value['msg'],
'type': value['type'],
'input': value['input'],
'ctx': value['ctx'],
};
}
/* tslint:disable */
/* eslint-disable */
export * from './HTTPValidationError';
export * from './LocationInner';
export * from './ValidationError';
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": ["ES2020", "DOM"]
},
"include": ["src/**/*.ts"],
"exclude": ["**/docs/**", "**/.openapi-generator/**"]
}
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "7.23.0"
}
}
#!/usr/bin/env python3
"""Refresh clients/openapi.json from the live app — the snapshot hexa + the
generated clients build against (#32, #44).
`app.app.openapi()` omits the public `servers` block (it's only injected when
served behind the gateway's root_path), so we add it here. test_openapi_snapshot
guards both the path set and this server URL — keep them in sync via THIS script.
~/.virtualenvs/douanier/bin/python clients/refresh_openapi.py
"""
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent)) # the service package (app.py, config.py)
import app # noqa: E402 (imported for its FastAPI instance)
import config # noqa: E402
PUBLIC_BASE = "https://api.nech.pl"
def main() -> None:
spec = app.app.openapi()
spec["servers"] = [{"url": PUBLIC_BASE + config.ROOT_PATH}]
out = HERE / "openapi.json"
out.write_text(json.dumps(spec, indent=2, ensure_ascii=False) + "\n")
ops = [o.get("operationId") for p in spec["paths"].values() for o in p.values()]
print(f"wrote {out} ({len(ops)} operations, server={spec['servers'][0]['url']})")
if __name__ == "__main__":
main()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment