diff --git a/.dagger/modules/e2e/main.dang b/.dagger/modules/e2e/main.dang index 39ca5c3..8718e8b 100644 --- a/.dagger/modules/e2e/main.dang +++ b/.dagger/modules/e2e/main.dang @@ -19,6 +19,9 @@ type E2e { let configuredDenoModulePath: String! = fixtureRoot + "/config/configured-deno" let configBareModulePath: String! = fixtureRoot + "/config/bare" + # A client binds to the generate/app module and is generated into its own dir. + let clientOutputPath: String! = fixtureRoot + "/client/out" + """ Fail the current check when a condition is false. """ @@ -203,7 +206,7 @@ type E2e { Generating all modules should discover TypeScript SDK modules and ignore skipped ones. """ pub generateAllCheck(ws: Workspace!): Void @check { - let changes = typescriptSdk.generateAll(ws) + let changes = typescriptSdk.generateAllModule(ws) assertAdded(changes, generateModulePath + "/sdk/index.ts") assert(contains(changes.addedPaths, lookupModulePath + "/src/index.ts") == false, "generateAll generated a lookup fixture that should be skipped") @@ -214,6 +217,32 @@ type E2e { null } + """ + Generating a client for a module should produce a scoped-package client + (client.gen.ts plus package.json) rooted at the client output path. + """ + pub generateClientCheck(ws: Workspace!): Void @check { + let changes = typescriptSdk.generateClient(ws, module: generateModulePath, path: clientOutputPath) + + assertAdded(changes, clientOutputPath + "/client.gen.ts") + assertAdded(changes, clientOutputPath + "/package.json") + + null + } + + """ + Regenerating all workspace-registered clients should generate each one at its + configured path. + """ + pub generateAllClientCheck(ws: Workspace!): Void @check { + let changes = typescriptSdk.generateAllClient(ws) + + assertAdded(changes, clientOutputPath + "/client.gen.ts") + assertAdded(changes, clientOutputPath + "/package.json") + + null + } + """ A skip marker above a module should make generate return an empty changeset. """ diff --git a/dagger.toml b/dagger.toml index 2002160..cfd5265 100644 --- a/dagger.toml +++ b/dagger.toml @@ -27,3 +27,7 @@ path = ".dagger/modules/e2e/fixtures/deps/app" [[modules.typescript-sdk.as-sdk.modules]] path = ".dagger/modules/e2e/fixtures/skip/app" + +[[modules.typescript-sdk.as-sdk.clients]] +path = ".dagger/modules/e2e/fixtures/client/out" +module = ".dagger/modules/e2e/fixtures/generate/app" diff --git a/design/client-gen.md b/design/client-gen.md new file mode 100644 index 0000000..b18feea --- /dev/null +++ b/design/client-gen.md @@ -0,0 +1,596 @@ +# Move TypeScript client codegen into the TypeScript SDK module + +## 1. Goal + +Today a TypeScript **client** (typed bindings for a module's dependency +closure) is generated by the engine: the engine introspects the schema and +calls the TypeScript SDK **runtime** (`sdk/typescript/runtime` in +`dagger/dagger`), which shells out to the Go `cmd/codegen` binary inside a +nested engine session. + +We want this `.dang` SDK module to generate the client itself, driven by a new +`@generate` method (`generateAllClient`) the engine dispatches — mirroring the +existing `generateAll` for modules. The SDK receives everything it needs as +plain data inputs (introspection JSON, dependency list) and runs codegen in an +ordinary container, so it **never opens a nested engine query** of its own. + +Three new product constraints: + +1. **Clients no longer coexist with modules.** A generated client is a + standalone artifact, not something layered next to a module's own bindings. +2. **A client binds a single module.** It is generated from that one module's + introspection schema (core + that module's dependency closure). +3. **A client is a scoped npm package.** The output directory gets its own + `package.json` (created if the directory is new or has none, otherwise + updated in place), declaring `@dagger.io/dagger` as a dependency. + +## 2. How client codegen works upstream today (`dagger/dagger`) + +### 2.1 The codegen binary + +`cmd/codegen` is a Go/cobra binary with subcommands (`main.go`): +`generate-client`, `generate-module`, `generate-library`, `generate-typedefs`, +`generate-entrypoint`, `introspect`. Only **`generate-client`** matters here. + +`Generate()` (`cmd/codegen/codegen.go`) is the shared driver: + +- Obtains an introspection `*Schema`, either by unmarshaling + `Config.IntrospectionJSON` **or** by introspecting the live engine + (`introspection.Introspect(ctx, cfg.Dag)`). When an introspection JSON is + supplied, **no engine call happens**. +- Calls the language generator's `GenerateClient`, then overlays the result + onto `Config.OutputDir` and runs any `PostCommands`. + +`GenerateClient` (`cmd/codegen/generate_client.go`) is the only piece that +still queries the engine even with a JSON schema: given `--module-source-id` it +runs `modsourcedeps.graphql` to fetch the module's **name**, **engineVersion**, +and **dependencies**. Those populate `generator.ClientGeneratorConfig` +(`ModuleName`, `EngineVersion`, `ModuleDependencies`). This is the nested +engine query we must eliminate. + +### 2.2 The TypeScript generator + +`cmd/codegen/generator/typescript`: + +- `GenerateClient` → `generate(config, "client.gen.ts", schema, version)` + (`generator.go`). +- `generate()` sorts the schema, then **splits dependency-contributed types** + into their own `.gen.ts` files: `client.gen.ts` is rendered from the + schema with dep-owned types excluded; each dependency gets an + `Include(depName)` sub-schema rendered through the `dep` template. + `selfModuleName(config)` (the module the client binds) keeps its own types in + `client.gen.ts`. With no dependencies this is a no-op. +- Templates live under `templates/src/*.ts.gtpl` (embedded via `//go:embed + all:src`). `templates.New(...)` wires them; template funcs are in + `templates/functions.go`. +- **Import resolution is the key lever** (`templates/src/header.ts.gtpl`): + - `IsBundle` → `import … from "./core.js"` (needs the bundled TS library + next to the client). + - client-only, not bundle → `import { … } from "@dagger.io/dagger"` plus a + generated `connection`/`connect` wrapper whose `serveModuleDependencies` + serves the module's git/local deps (uses `ClientConfig.ModuleDependencies`). + - `--bundle` is **only** added for modules, never for `generate-client` + (`lib_generator.go`: `if libOrigin == Bundle && !genClient`). So a + standalone client's `client.gen.ts` **always imports the bare specifier + `@dagger.io/dagger`** — the runtime decides whether that resolves to the + npm package (remote) or a vendored `./sdk` bundle (via tsconfig `paths`). + +### 2.3 The runtime `GenerateClient` (what we're replacing) + +`sdk/typescript/runtime/main.go` `GenerateClient(modSource, introspectionJSON, +outputDir)`: + +1. `analyzeClientConfig` (`config.go`) inspects the target dir's + `package.json` / `deno.json` to detect runtime (node/bun/deno) and + `SDKLibOrigin` (`Bundle` default / `Remote` if `@dagger.io/dagger` is a + versioned dep / `Local` if `./sdk`). +2. `LibGenerator` (`lib_generator.go`) runs the `/codegen` binary in a bun + container with the introspection file mounted (`--introspection-json-path`), + emitting `client.gen.ts` + `.gen.ts`. For `Bundle` it additionally + lays the static library into `sdk/`; for `Remote` it emits only the + bindings. +3. Writes/updates config: `CreateOrUpdatePackageJSONForClient`, + `CreateOrUpdateTSConfigForClient` (or `UpdateDenoJSONForClient`) — see + `config_updator.go` + `tsutils/`. +4. Returns a `Directory` rooted at the module path. + +The introspection JSON itself is produced by the **engine**, not the runtime: +`core/schema/modulesource.go` `moduleSourceGeneratedClient` builds +`codegenDeps` (deps + the target module promoted to `Query`) and calls +`SchemaIntrospectionJSONFileForClient`, then hands the file to the SDK's +`GenerateClient` (`core/modulesource.go:680`). + +### 2.4 Where the engine calls the SDK + +- `core/sdk/dang_sdk.go`: the Dang SDK currently returns *"dang SDK does not + have a client to generate"* from `GenerateClient` — this is the gap the + parallel `dagger/dagger` work closes. +- `core/sdk/module_client_generator.go`: for module-based SDKs the engine + selects the SDK's `generateClient(modSource, introspectionJson, outputDir)` + field. +- Workspace-level regen: `core/schema/workspace.go` `clientGenerate` + ("Regenerate all generated API clients registered in workspace config") + iterates `entry.AsSDK.Clients` (`core/workspace/config.go` `SDKManagedClient{ + Path, Module, Pin, Options }`) → `runClientGenerator` → the per-client engine + path above. **`generateAllClient` is the SDK-side counterpart of this loop.** + +### 2.5 How the binary/library are built upstream + +`toolchains/engine-dev/build/sdk.go`: `/codegen` is `build.CodegenBinary()` +(the Go binary); `/bundled_lib` is `bun build ./src/index.ts` (core.js) + +rolled-up `core.d.ts`; `/bin/ts-introspector` and `/typescript-library` are +also produced. These live in the engine-shipped `SDKSourceDir`. **This repo +does not contain the `@dagger.io/dagger` TS library source**, only the `.dang` +SDK module + Go helpers. + +## 3. Target architecture in this repo + +### 3.1 The dang-visible surface we build on + +The SDK module already reaches the engine through typed dang APIs — no raw +introspection needed. This design depends on **two new engine primitives** the +companion `dagger/dagger` proposal +([`hack/designs/client-codegen-workflow.md`](../../dagger/hack/designs/client-codegen-workflow.md)) +adds (both gated `v1.0.0-0`, additive — phase 1 of that doc): + +- `currentModule.asSDK.clients` → `[{ path, module, pin }]` + (`core/schema/module_as_sdk.go`, gated `v1.0.0-0`). The workspace source of + truth for registered clients, mirroring `.modules` used by `generateAll`. +- **`CurrentModuleAsSDKClient.moduleSource: ModuleSource!`** *(new)* — resolves + the bound module from `{ module, pin }`, including canonical/remote refs, via + the engine's `resolveClientTargetModule`. We **must not** resolve it ourselves + by path (`polyfill.moduleSource(client.module)`): that only handles local + paths and ignores the pin, so it breaks for remote-ref clients. +- **`ModuleSource.clientSchemaIntrospectionJSON: File!`** *(new)* — **the schema + codegen consumes.** This is the *client-facing* schema + (`SchemaIntrospectionJSONFileForClient`, `core/moddeps.go:162`): full schema, + **no hidden types**, deps loaded, and the target module's own types promoted + to `Query` for self-bindings. + +> ⚠️ Do **not** use the existing `moduleSource.introspectionSchemaJSON` (the +> polyfill field). That is the *module-facing* schema +> (`SchemaIntrospectionJSONFileForModule`) which **hides** +> `TypesToIgnoreForModuleIntrospection` + `TypesHiddenFromModuleSDKs` and does +> **not** promote the module's own types. Feeding it to client codegen produces +> an incomplete/incorrect client. The two schemas are deliberately different. + +From the resolved `client.moduleSource` (a full core `ModuleSource`) we also +read the metadata codegen needs but that is *not* in the schema JSON: the +structured dependency list (`dependencies { kind, moduleOriginalName, pin, +asString }`), `moduleOriginalName`, and `engineVersion`. + +> ✅ **Confirmed by the engine author** (companion doc §Open questions Q1): +> `CurrentModuleAsSDKClient.moduleSource` resolves via `resolveClientTargetModule` +> and returns the **plain** resolved `*core.ModuleSource` — not an +> `_implementationScoped` form. `dependencies`, `moduleOriginalName`, +> `engineVersion`, and per-dep `kind`/`pin`/`asString` are all `field:"true"` on +> `core.ModuleSource` (the exact set `modsourcedeps.graphql` reads today), so +> provenance is guaranteed selectable from Dang. This is load-bearing for +> portable clients (embedded `source`+`pin`) and now has an engine regression test. + +So the SDK gets the schema and dep list as data. The only compute it runs is a +plain container executing our Go codegen — **no `experimentalPrivilegedNesting` +for the client step**. The polyfill is still used for the workspace `fork`/ +Changeset assembly and reading existing files under the client dir, but **not** +for schema or module resolution. + +### 3.2 Lib-origin decision: default to **Remote** (scoped package) + +Because a standalone client's `client.gen.ts` always imports the bare +`@dagger.io/dagger` (§2.2), the simplest correct artifact is a **scoped npm +package** whose `package.json` declares `@dagger.io/dagger` as a versioned +dependency (pinned to the module's `engineVersion`). This: + +- satisfies constraint #3 directly (a real package.json with a name + dep); +- needs **none** of the bundled TS library (`bundled_lib`, `core.d.ts`, static + `index.ts`), so we do **not** have to vendor or build the `@dagger.io/dagger` + source in this repo; +- keeps the moved surface area to: the Go codegen + package/tsconfig writing. + +`Bundle`/`Local` origins (offline, no-npm) are explicitly **out of scope**: the +client always depends on the npm `@dagger.io/dagger`. If ever needed, a bundled +artifact would be produced by a separate dang module in `dagger/dagger`, not +shipped here (§7). + +### 3.3 Data flow + +``` +engine ──dispatch──▶ TypescriptSdk.generateAllClient(ws) [@generate] + │ + ├─ currentModule.asSDK.clients # {path, module, pin} + │ + └─ for each client: + modSrc = client.moduleSource # NEW engine field + schema = modSrc.clientSchemaIntrospectionJSON # NEW engine field (File) + name = modSrc.moduleOriginalName + engineV = modSrc.engineVersion + deps = modSrc.dependencies{kind,name,pin,source} + │ + ├─ codegen helper (container): + │ generate-client + │ --introspection-json-path schema.json + │ --client-meta-path meta.json # name, engineVersion, deps + │ --output + │ ⇒ client.gen.ts (+ .gen.ts) + │ + └─ scoped-package writer (container/helper): + create-or-update package.json (name, type:module, + deps: @dagger.io/dagger@, typescript), + tsconfig.json (or deno.json for deno) + ▼ + fork.merge(...).changes ⇒ Changeset +``` + +## 4. Components to move / build + +### A. Go codegen helper — `helpers/codegen` + +Bring the TypeScript generator into this repo as a self-contained Go helper, +compiled in a container exactly like `render-template` / `config-updator` / +`module-config`. + +Move (copy, then trim) from `dagger/dagger`: + +- `cmd/codegen/generator/typescript/**` (generator + `templates/**`, the + `.gtpl` files, `functions.go`, `format.go`, `entrypoint*.go` if we also want + entrypoint rendering later — otherwise omit). +- `cmd/codegen/introspection/**` (schema model, `Include`/`Exclude`, parents). +- `cmd/codegen/generator/{generator.go,config.go,schema.go,overlay.go,functions.go}` + (the generator interface, `Config`, `GeneratedState`, overlay writer). +- A slimmed `main.go` exposing **only** `generate-client` (and its shared + `Generate`/overlay driver from `codegen.go`). + +Trim / modify: + +1. **Drop the engine-connected paths.** Remove `introspection.Introspect` usage + and the `Config.Dag *dagger.Client` field so the helper does **not** depend + on `dagger.io/dagger` (leaner, faster build, no nesting). `Generate()` + always takes `IntrospectionJSON` from `--introspection-json-path`. +2. **Replace the `--module-source-id` engine query** in `generate_client.go`. + Instead of `modsourcedeps.graphql`, read `ClientGeneratorConfig` + (`ModuleName`, `EngineVersion`, `ModuleDependencies`) from a + `--client-meta-path meta.json` file that the dang side writes from + `moduleSource.core`. Keep `ModuleSourceDependency{Kind,Name,Pin,Source}` as + the JSON shape (it already matches the template funcs + `Dependencies`/`HasLocalDependencies`). +3. Keep the dep-splitting logic (`Exclude`/`Include`, `.gen.ts`) as-is. +4. Vendor transitive deps: `iancoleman/strcase`, `psanford/memfs`, + `tidwall/{gjson,sjson}` (only if reused), plus the introspection package's + own deps. Pin `go.mod` like the other helpers. + +Output: the helper writes `client.gen.ts` (+ any `.gen.ts`) into +`--output`. The dang side reads that directory back. + +### B. Client bindings generation in dang + +Add to `typescript-sdk.dang` (and a small `Client` type in a new `client.dang`, +mirroring `mod.dang`): + +- `pub generateAllClient(ws: Workspace!): Changeset! @generate` — iterate + `currentModule.asSDK.clients`, generate each, reduce into a fork, return + `.changes`. Structural twin of `generateAll` (renamed `generateAllModule` for + symmetry — see §7). The `ws: Workspace!` arg is **engine-injected**: the rollup + selects the generator with default args only and does not pass `ws` per-call — + identical to today's `generateAll(ws)` (confirmed, companion doc Q5). So this is + the *same* arg shape as the module generator, no new plumbing; the engine passes + nothing else and expects only the `Changeset` back. +- A per-client `generate` that: + 1. takes the resolved `modSrc = client.moduleSource` (the new engine field — + **not** a polyfill path lookup), + 2. builds a `codegenBuilder` container (Go build of `helpers/codegen`, cached + like `moduleConfigBuilder`), + 3. mounts `modSrc.clientSchemaIntrospectionJSON` (the client-facing schema) + and a `meta.json` rendered from `modSrc` (`moduleOriginalName`, + `engineVersion`, `dependencies`), + 4. `withExec(["codegen", "generate-client", "--introspection-json-path", …, + "--client-meta-path", …, "--output", "/out"])`, + 5. returns `/out` as a `Directory`. +- Detect the target runtime for the client dir (reuse `Mod`/`ModConfig` + runtime detection: node/bun vs deno) to choose package.json vs deno.json. + +### C. Scoped-package writer + +The `@dagger.io/dagger` version pin and package identity are new. Two options — +prefer reusing the existing Go helper: + +- **Extend `helpers/config-updator`** (already owns package.json/tsconfig/deno + edits and the `@dagger.io/dagger` path aliases) with a `client-package-json` + mode that, given the existing file (or none): + - sets `type: "module"`, + - ensures `dependencies["@dagger.io/dagger"] = ""`, + - ensures `dependencies.typescript = `, + - sets `name` when absent (scoped, see §7), + and a matching `client-tsconfig` / `deno-config` mode. This mirrors upstream + `UpdatePackageJSONForClient` / `UpdateTSConfigForClient` / + `UpdateDenoConfigForClient` (`tsutils/`), which we port rather than re-invent. +- The dang side runs it against `ws.directory(clientPath, include:[package.json, + tsconfig.json, deno.json])` so existing user config is preserved. + +### D. Dependency serving + +Pass `dependencies` into `meta.json` (item A.2); the generated +`serveModuleDependencies` in `header.ts.gtpl` consumes them. The codegen must +enumerate **all three** `ModuleSourceKind` values explicitly (`core/modulesource.go:55-60`) +rather than the current "GIT vs else" branching, which silently drops anything +that isn't `GIT_SOURCE`/`LOCAL_SOURCE`: + +- **`GIT_SOURCE`** → embed `source` + `refPin` (portable; runs anywhere). +- **`LOCAL_SOURCE`** → the existing `configExists`-guarded local `.`-serve + (**workspace-only**; a local dep has no portable ref — inherent, engine + confirmed it can't help). A bound module with local deps is **generated, not + rejected**; git deps stay portable, local deps degrade gracefully at runtime + (the template already warns when `dagger.json` is absent). Emit a + **codegen-time log** — "client X is workspace-only (module has local + dependencies)" — so it isn't a silent surprise. +- **`DIR_SOURCE`** → **fail-closed / assert.** The engine confirmed (companion + doc Q8 follow-up) a `DIR_SOURCE` **cannot** appear in a persisted client's + bound-module dep list — `ParseRefString` only yields LOCAL/GIT; `DIR_SOURCE` + comes solely from the programmatic `dag.moduleSource(dir)` path. So codegen + errors on it rather than emitting an unservable dep (which would compile but + fail at runtime). Do **not** add a serve branch. + +Kind strings confirmed to match the enum: `GIT_SOURCE` / `LOCAL_SOURCE` / +`DIR_SOURCE`. + +## 5. Step-by-step implementation plan + +> **Implementation status** (spike landed, engine-independent parts done): +> - ✅ **Steps 1–3 done.** `helpers/codegen` is a standalone Go module (deps: +> `iancoleman/strcase`, `psanford/memfs`, `golang.org/x/mod`; **no +> `dagger.io/dagger`**). Slim `main.go` exposes `generate-client` with +> `--introspection-json-path` / `--client-meta-path` / `--output`; the +> `--module-source-id` engine query is gone (deps come from `meta.json`). The +> `DIR_SOURCE` fail-closed guard (§4.D) is implemented + unit-tested. Upstream +> golden tests (dep-split, objects, object, type) ported and green; the +> live-engine `functions_test.go` was dropped (it needed `Introspect`). +> End-to-end verified: `client.gen.ts` imports bare `@dagger.io/dagger`, git +> deps embed `source`+`refPin`, local deps take the guarded serve, dep-splitting +> keys off `moduleName`. +> - ✅ **Step 4 done.** `helpers/config-updator` gained `client-package-json` +> (scoped-package writer: `type:module`, `@dagger.io/dagger`=engine version with +> `v` stripped / `-dev` kept, `typescript` pinned if absent, scoped `name` +> `@dagger.io/-client` when unnamed), `client-tsconfig` (Remote: standard +> compilerOptions, no `./sdk` paths), and `client-deno-config` (writes +> `imports["@dagger.io/dagger"]=npm:@dagger.io/dagger@` + telemetry — new +> always-Remote behavior) + unit tests. +> - ✅ **Steps 6–7 written** (dang). `typescript-sdk.dang` gains `codegenBuilder` + +> `configUpdatorBuilder` containers; private helpers `generateClientBindings` / +> `configureClientNode` / `dependenciesJSON` / `clientDirectory`; the +> workspace-oriented `generateClient(ws, module, path)` (analogue of +> `mod(ws, path).generate(ws)`); `generateAllModule` (renamed from `generateAll`, +> e2e ref updated); and `generateAllClient(ws) @generate` iterating +> `currentModule.asSDK.clients` via `client.moduleSource`. (Deno client config is +> scaffolded in config-updator but not yet wired into `clientDirectory`.) +> - ✅ **Step 9 done — verified the repo way (not scripts).** Added e2e +> `generateClientCheck` (calls `generateClient(ws, module: generate/app, +> path: client/out)`) and `generateAllClientCheck` (calls `generateAllClient(ws)`), +> both asserting `client.gen.ts` + `package.json` at the client path; registered a +> client in `dagger.toml` (`[[modules.typescript-sdk.as-sdk.clients]]` → generate/app). +> These are the client analogues of `generateCheck` / `generateAllCheck`. +> - ⏳ **Blocked on engine.** The dang module requires engine `v1.0.0-0` (installed +> is v0.21.7), and the new client checks additionally need the phase-1 primitives +> (`clientSchemaIntrospectionJSON`, `moduleSource`). Like every other `ws:`-based +> check they run only on a CLI-1.0 engine. The isolated codegen/config logic stays +> verifiable now via `go test ./...` in each helper. +> - ⚠️ **Verify on a real engine:** that `toJSON(dep.kind)` renders `"GIT_SOURCE"`/ +> `"LOCAL_SOURCE"` (not the `"GIT"`/`"LOCAL"` enum alias) so the generated +> `serveModuleDependencies` matches; and that `container.withNewFile` / the fork +> `withDirectory` accumulation behave as assumed in `generateAllClient`. + +1. **Scaffold `helpers/codegen`.** Copy the generator + introspection + templates + from `dagger/dagger`; add `go.mod`/`go.sum`; get it building standalone + (no `dagger.io/dagger` import). Keep upstream unit tests + (`*_test.go`, `templates/src/testdata/*`) to guard the port. +2. **Slim the CLI to `generate-client`.** Wire `--introspection-json-path`, + `--client-meta-path`, `--output`. Replace the `--module-source-id` engine + query with `meta.json` parsing. Delete `introspect.go`, `Config.Dag`, and the + other subcommands. +3. **Golden test the helper.** Feed a captured `introspection.json` + `meta.json` + (with and without dependencies) and assert `client.gen.ts` / `.gen.ts` + match upstream output byte-for-byte (reuse upstream `dep_split_test.go` + fixtures). +4. **Port the client config writers** into `helpers/config-updator` + (`client-package-json`, `client-tsconfig`, `client-deno-config`) with tests + ported from `tsutils/*_updator_test.go`. +5. **Wait for the engine primitives to ship** (companion doc phase 1) on the + pinned engine: `CurrentModuleAsSDKClient.moduleSource` + + `ModuleSource.clientSchemaIntrospectionJSON` (both gated `v1.0.0-0`); the + resolved `moduleSource` exposing `dependencies` / `moduleOriginalName` / + `engineVersion` is **confirmed** (§3.1). The generator can't light up before + these ship — but the codegen port (steps 1–4) is fully independent and can + start now. +6. **Add the dang `Client` type + generation** (`client.dang`): take + `client.moduleSource`, build `meta.json`, mount + `clientSchemaIntrospectionJSON`, run the codegen helper, run the config + writer, assemble the client `Directory`. +7. **Add `generateAllClient(ws)` `@generate`** to `TypescriptSdk`, iterating + `currentModule.asSDK.clients` and reducing per-client changesets into a fork. + Add a `codegenBuilder` container helper next to `moduleConfigBuilder`. Rename + the existing `generateAll` → `generateAllModule` for symmetry (the companion + doc's CLI expects `/generate-all-module` + `/generate-all-client`). +8. **`initClient` emits nothing — drop the hook (decided).** A client has no + author-editable source (unlike a module's scaffolded `index.ts`), so there is + no init-time emit: `clientInit` is a **pure config write** and all files come + from `generateAllClient` on the rollup. `initClient` returns an empty + Changeset (already shape-A). The engine removes the `AsClientInitializer`/ + `InitClient` path in phase-3 cleanup (companion doc Q7, accepted). Nothing to + build here beyond keeping the empty Changeset. +9. **Coordinate the engine contract** with the parallel `dagger/dagger` work: + the Dang SDK's client path must dispatch `generateAllClient` (the `@generate` + method) as a discoverable rollup generator, replacing the removed + `clientGenerate`/`runClientGenerator` special-case (companion doc phase 3). + Confirm the exact field name/signature the engine selects. +10. **e2e fixtures.** Add a `client` fixture under + `.dagger/modules/e2e/fixtures/` (a module with 0 deps and one with a git + + local dep) and `.dagger.toml` `[[…as-sdk.clients]]` entries. Assert the + generated package.json (name, `@dagger.io/dagger` pin, `type:module`), + `client.gen.ts` presence, and `.gen.ts` splitting. +11. **Docs/changelog** and remove the now-dead runtime client path upstream once + this ships (separate PR in `dagger/dagger`). + +## 6. Cleanup: move and simplify at the same time + +The upstream client path carries a lot of accumulated generality (three lib +origins, Go+TS in one binary, a live-engine query, module/client coexistence). +Because our new contract is narrow — **always Remote, client-only, single +module, schema-as-data** — most of it is dead on arrival. We port the *core +generator* and drop the rest, rather than copying it verbatim and cleaning +later. + +### 6.1 Do NOT carry into `helpers/codegen` (simplify while porting) + +- **The live-engine query.** `generate_client.go`'s `--module-source-id` + + `modsourcedeps.graphql` + `getGlobalConfig` engine dial all go away — + replaced by the `meta.json` input (§4.A.2). Delete `modsourcedeps.graphql`, + `introspect.go`, and `introspection.Introspect` usage. `Config.Dag + *dagger.Client` is removed, which drops the whole `dagger.io/dagger` Go-SDK + dependency from the helper. +- **The Go generator.** Bring only `generator/typescript` (+ `introspection`, + + the shared `generator` interface types). Do not bring `generator/go/**`. + Everything keyed on it — `getGenerator`'s `SDKLangGo` case, the `--lang` + flag's `go` default — collapses to TypeScript-only. +- **`ClientGeneratorConfig.ClientDir` / `--client-dir`.** Confirmed unused by + the TS generator: TS writes `client.gen.ts` relative to `OutputDir` + (`generator.go`), while `ClientDir` only matters for Go's `go.mod` placement + (`generator/go/generate_client.go`). Drop the field and the flag; keep only + `--output`. +- **`NeedRegenerate` re-run loop + `PostCommands`.** Both are Go-only (TS emits + neither — verified: no references under `generator/typescript`). The TS-only + driver runs the generator once and overlays; no loop, no `exec.Cmd` plumbing. +- **`GenerateLibrary` / `generate-library` / `GenerateTypeDefs`.** For TS, + `GenerateClient` and `GenerateLibrary` are byte-identical (`generate(cfg, + ClientGenFile, …)`), and `GenerateTypeDefs` just errors "not implemented". + Keep a single `generate-client` entrypoint; drop the other two commands and + interface methods from the moved copy. +- **`--bundle` and the `IsBundle` template branch.** We never bundle a client + (upstream already forces `!genClient` before adding `--bundle`). Drop the + `Bundle` config field and prune the `IsBundle` arm of + `templates/src/header.ts.gtpl`, leaving only the `@dagger.io/dagger` + client-only import path. + +Net: the moved helper is `generate-client` + the TS templates + introspection +model + a `meta.json` reader — no engine client, no Go generator, no +origin/bundle branching. + +### 6.2 Delete from `dagger/dagger` once this ships (coordinate with the parallel work) + +The engine no longer drives TypeScript client generation through the runtime, +so the following become dead and should be removed in the companion PR: + +- **`sdk/typescript/runtime/GenerateClient`** (`main.go:184`) and its whole + fan-out for clients. +- **`Local` lib origin end-to-end.** `LibGenerator.GenerateLocalLibrary` + + `StaticLocalLib` (`lib_generator.go`), the `Local` constant and its three + `detectSDKLibOrigin` branches (`config.go`) — already flagged upstream with + *"TODO: deprecate local lib support"*. Our decision (§7, always Remote) + retires it. +- **Client bundling in the runtime.** The `GenerateBundleLibrary` client arm, + `StaticBundleClientIndexTS`, and the `coexistWithModule` flag (`lib_generator.go`, + `main.go`) — clients no longer coexist with modules (constraint #1). +- **`analyzeClientConfig`** (`config.go`), a near-duplicate of + `analyzeModuleConfig` whose only client-specific job (lib-origin detection) + disappears with Remote-only. +- **`CreateOrUpdate*ForClient`** runtime wrappers (`config_updator.go`) — the + logic moves into `helpers/config-updator` here (§4.C); the runtime copies are + redundant. +- **`ClientGeneratorConfig.ClientDir`** in `generator/config.go` if the Go + client generator is also migrated to `meta.json`; otherwise leave for Go. + +Keep (still used by module codegen / other SDKs): the `generate-module` and +`generate-entrypoint` commands, `introspector.go`, the module lib generation, +and `RequiredClientGenerationFiles` (the Dang SDK already returns an empty list, +`dang_sdk.go`). + +## 7. Decisions + +- **Scoped package name — DECIDED.** When the client dir has no `package.json`, + write a fixed scoped name: **`@dagger.io/-client`** + (`sanitized` = the bound module's original name lower-cased, non-alphanumerics + → `-`). If a `package.json` already exists, leave its `name` untouched. +- **`@dagger.io/dagger` version source — DECIDED.** Use + `client.moduleSource.engineVersion` (matches upstream + `ClientGeneratorConfig.EngineVersion`). A dev engine version is fine: keep the + `-dev`/pre-release suffix as-is. It may not be publishable to npm, but the + generated client is still installable/executable against that engine, which is + what matters for `dagger develop`. +- **Lib origin — DECIDED: always Remote (`@dagger.io/dagger`).** We do not ship a + bundle in this repo. The client is always a scoped package depending on the + npm `@dagger.io/dagger`. If a bundled/offline variant is ever needed, add a + small dang module in `dagger/dagger` that produces the bundled-lib artifact + (`core.js`/`core.d.ts` + static `index.ts`) on demand — tracked separately, + not part of this work. +- **Regeneration — DECIDED: regenerate all.** `generateAllClient` (re)generates + **every** registered TypeScript client each pass, exactly like `generateAll` + does for modules. No single-client entrypoint. +- **Generator naming — DECIDED: align with the companion doc.** Add + `generateAllClient` (renders as `/generate-all-client`) and rename the + existing `generateAll` → `generateAllModule` (`/generate-all-module`) so + both are discoverable, selectable rollup generators — the CLI shape the engine + proposal specifies. + +### Confirmed with the engine author + +All engine-side questions are resolved. The system of record is the companion +doc's *"Open questions from the typescript-sdk client-gen design"* section +(`hack/designs/client-codegen-workflow.md`, Q1–Q6). Outcomes: + +- **Provenance (Q1) — confirmed, load-bearing.** `CurrentModuleAsSDKClient.moduleSource` + is the plain resolved `*core.ModuleSource` (via `resolveClientTargetModule`), not + an `_implementationScoped` form; `dependencies` / `moduleOriginalName` / + `engineVersion` / per-dep `kind`/`pin`/`asString` are all selectable (the set + `modsourcedeps.graphql` already reads). Engine regression test added. See §3.1. +- **No workspace runtime API (Q2) — agreed.** Embedded `source`+`pin` is the correct + portable-client model; no new client-facing workspace dep-resolution API. +- **Field names (Q3) — final & stable.** `moduleSource.clientSchemaIntrospectionJSON: + File!` and `CurrentModuleAsSDKClient.moduleSource: ModuleSource!`, both gated + `AfterVersion("v1.0.0-0")`, out of `base_schema.json`. Name churn is over. +- **Naming (Q4) — engine is naming-agnostic.** The rollup renders each `@generate` + function by the kebab-case of its Dang name, so `generateAllClient` → + `generate-all-client` automatically. We keep the rename decision above + (`generateAll` → `generateAllModule`). +- **Dispatch (Q5) — confirmed pure `@generate` rollup.** Engine passes nothing extra, + expects only the `Changeset`; `clientGenerate`/`runClientGenerator` fully removed. + The `ws: Workspace!` arg is engine-injected (rollup selects with default args) — + same shape as today's `generateAll(ws)`, so no new plumbing. See §4.B. +- **Framing (Q6) — fixed** in the companion doc (this repo = the split-out `.dang` + module; `helpers/codegen` binary; `sdk/typescript/runtime` is the retired path). +- **`initClient` hook (Q7) — dropped.** Clients have no author-editable source, so + `clientInit` is a pure config write; the engine removes `AsClientInitializer`/ + `InitClient` in phase-3 cleanup. `initClient` stays an empty Changeset. See §5 step 8. +- **Local-dep clients (Q8) — generated, not rejected.** Git deps embed + `source`+`pin` (portable); local deps take the workspace-only guarded serve; the + engine provides nothing (confirmed it can't). See §4.D. +- **`DIR_SOURCE` (Q8 follow-up) — fail-closed.** Confirmed a `DIR_SOURCE` cannot + appear in a persisted client's bound-module dep list; codegen asserts on it rather + than emitting an unservable dep. See §4.D. + +### Remaining task (not a blocker) + +- **Deno clients.** Port `UpdateDenoConfigForClient` so `deno.json` + `imports["@dagger.io/dagger"]` is set to `npm:@dagger.io/dagger@`, and + add a deno e2e fixture to validate. Same Remote model as node/bun, just a + different config file. + +## 8. Key upstream references + +- **Companion engine proposal:** `dagger/dagger` + `hack/designs/client-codegen-workflow.md` (the `clientSchemaIntrospectionJSON` + + `CurrentModuleAsSDKClient.moduleSource` primitives, phased sequencing, and + engine-side cleanup this design pairs with). +- `cmd/codegen/generate_client.go`, `codegen.go`, `generator/config.go` +- `cmd/codegen/generator/typescript/{generator.go, templates/**}` (esp. + `templates/src/header.ts.gtpl`, `functions.go`) +- `sdk/typescript/runtime/{main.go:184, lib_generator.go, config.go, + config_updator.go}`, `tsutils/{package_json_updator,tsconfig_updator, + deno_config_updator}.go` +- **Schema builders:** `core/moddeps.go:162` + (`SchemaIntrospectionJSONFileForClient` — no hidden types — vs + `SchemaIntrospectionJSONFileForModule` — hidden types). +- `core/schema/{modulesource.go:2700, workspace.go, workspace_client.go, + module_as_sdk.go}`, `core/{workspace/config.go (SDKManagedClient), + current_module_as_sdk.go}` +- `sdk-sdk/polyfill/module-source.dang` (`core`, `fork`; **not** + `introspectionSchemaJSON` — that's the module-facing schema, wrong for clients) +- This repo: `typescript-sdk.dang` (`generateAll` → `generateAllModule`, + `moduleConfigBuilder`, `initClient`), `mod.dang`, + `helpers/{config-updator,module-config}` diff --git a/helpers/codegen/generator/config.go b/helpers/codegen/generator/config.go new file mode 100644 index 0000000..64f8de7 --- /dev/null +++ b/helpers/codegen/generator/config.go @@ -0,0 +1,102 @@ +package generator + +type Config struct { + // Lang is the language to generate the module for. + Lang SDKLang + + // OutputDir is the path to put the generated code. + // Usually this is the path to the module source directory. + // This allows generating extra file aside the client bindings + // like go.mod, go.sum etc... + OutputDir string + + // IntrospectionJSON is an optional pre-computed introspection json string. + IntrospectionJSON string + + // TypeDefsPath is the path of the file to write the typedefs module id. + TypeDefsPath string + + // Generate the client in bundle mode. + Bundle bool + + // ModuleConfig is the specific config to generate module or typedefs. + ModuleConfig *ModuleGeneratorConfig + + // ClientConfig is the specific config to generate standalone client. + ClientConfig *ClientGeneratorConfig + + // EntrypointConfig is the specific config to generate the static dispatch + // entrypoint file (currently TypeScript only). + EntrypointConfig *EntrypointGeneratorConfig +} + +// Specific configuration for module generation. +type ModuleGeneratorConfig struct { + // Name of the module to generate code for. + ModuleName string + + // ModuleSourcePath is the subpath in OutputDir where the module source subpath is located. + ModuleSourcePath string + + // ModuleParentPath is the path from the module source subpath to the context directory + ModuleParentPath string + + // Whether we are initializing a new module. + // Currently, this is only used in go codegen to enforce backwards-compatible behavior + // where a pre-existing go.mod file is checked during dagger init for whether its module + // name is the expected value. + IsInit bool + + // If set, use `@dagger.io/dagger` with the given version and use it in the generated client. + LibVersion string +} + +type ModuleSourceDependency struct { + Kind string + Name string `json:"moduleOriginalName"` + Pin string + Source string `json:"asString"` +} + +// Specific configuration for entrypoint generation. +type EntrypointGeneratorConfig struct { + // TypedefJSONPath is the path to the JSON-serialized DaggerModule typedef + // produced by the SDK introspector (e.g. ts-introspector with + // EMIT_TYPEDEF_JSON_FILE). + TypedefJSONPath string + + // OutputFile is the filename (relative to OutputDir) where the generated + // entrypoint source is written. Defaults to "__dagger.entrypoint.ts" for + // the TypeScript SDK. + OutputFile string + + // ModuleRoot is the absolute path of the user's module root, used to + // resolve relative source-import paths for each registered @object class. + ModuleRoot string + + // SDKImportPath is the bare specifier the entrypoint uses to import + // runtime helpers (defaults to "@dagger.io/dagger" for TypeScript). + SDKImportPath string + + // SourceDir is the user's source directory name relative to ModuleRoot + // (defaults to "src" for TypeScript). + SourceDir string +} + +// Specific configuration for client generation. +type ClientGeneratorConfig struct { + // The name of the module to generate for. + ModuleName string + + // The list of all dependencies used by the module. + // This is used by the client generator to automatically serves the + // dependencies when connecting to the client. + ModuleDependencies []ModuleSourceDependency + + // The directory where the client will be generated. + ClientDir string + + // The engine version from dagger.json, used to pin the dagger.io/dagger dependency. + // This is only populated when generating from a module source (not in tests). + EngineVersion string +} diff --git a/helpers/codegen/generator/functions.go b/helpers/codegen/generator/functions.go new file mode 100644 index 0000000..a7a0e0b --- /dev/null +++ b/helpers/codegen/generator/functions.go @@ -0,0 +1,260 @@ +package generator + +import ( + "fmt" + "strings" + "unicode" + + "codegen/introspection" + "golang.org/x/mod/semver" +) + +const ( + QueryStructName = "Query" + QueryStructClientName = "Client" +) + +// FormatTypeFuncs is an interface to format any GraphQL type. +// Each generator has to implement this interface. +type FormatTypeFuncs interface { + WithScope(scope string) FormatTypeFuncs + + FormatKindList(representation string) string + FormatKindScalarString(representation string) string + FormatKindScalarInt(representation string) string + FormatKindScalarFloat(representation string) string + FormatKindScalarBoolean(representation string) string + FormatKindScalarDefault(representation string, refName string, input bool) string + FormatKindObject(representation string, refName string, input bool) string + FormatKindInputObject(representation string, refName string, input bool) string + FormatKindEnum(representation string, refName string) string +} + +// CommonFunctions formatting function with global shared template functions. +type CommonFunctions struct { + schemaVersion string + formatTypeFuncs FormatTypeFuncs +} + +func NewCommonFunctions(schemaVersion string, formatTypeFuncs FormatTypeFuncs) *CommonFunctions { + return &CommonFunctions{schemaVersion: schemaVersion, formatTypeFuncs: formatTypeFuncs} +} + +// IsSelfChainable returns true if an object type has any fields that return +// that same type, and does not already have a field named "with" (which would +// conflict with the generated With helper method). +func (c *CommonFunctions) IsSelfChainable(t introspection.Type) bool { + selfChainable := false + for _, f := range t.Fields { + // If there's already a "with" field, the generated With helper + // would conflict with it — skip generating the helper. + if f.Name == "with" { + return false + } + // Only consider fields that return a non-null object. + if !f.TypeRef.IsObject() || f.TypeRef.Kind != introspection.TypeKindNonNull { + continue + } + if f.TypeRef.OfType.Name == t.Name { + selfChainable = true + } + } + return selfChainable +} + +func (c *CommonFunctions) InnerType(t *introspection.TypeRef) *introspection.TypeRef { + switch t.Kind { + case introspection.TypeKindNonNull: + return c.InnerType(t.OfType) + case introspection.TypeKindList: + return c.InnerType(t.OfType) + default: + return t + } +} + +func (c *CommonFunctions) ObjectName(t *introspection.TypeRef) (string, error) { + switch t.Kind { + case introspection.TypeKindNonNull: + return c.ObjectName(t.OfType) + case introspection.TypeKindObject, introspection.TypeKindInterface: + return t.Name, nil + default: + return "", fmt.Errorf("unexpected type kind %s", t.Kind) + } +} + +func (c *CommonFunctions) IsIDableObject(t *introspection.TypeRef) (bool, error) { + schema := GetSchema() + switch t.Kind { + case introspection.TypeKindNonNull: + return c.IsIDableObject(t.OfType) + case introspection.TypeKindObject: + schemaType := schema.Types.Get(t.Name) + if schemaType == nil { + return false, fmt.Errorf("schema type %s is nil", t.Name) + } + + for _, f := range schemaType.Fields { + if f.Name == "id" { + return true, nil + } + } + return false, nil + case introspection.TypeKindInterface: + // Interfaces are always IDable (they represent objects that implement them). + return true, nil + default: + return false, nil + } +} + +// FormatReturnType formats a GraphQL type into the SDK language output, +// unless it's an ID that will be converted which needs to be formatted +// as an input (for chaining). +func (c *CommonFunctions) FormatReturnType(f introspection.Field, scopes ...string) (string, error) { + if c.ConvertID(f) { + // When converting an ID return to an object (e.g. sync), + // use the parent object's name as the return type. + scope := strings.Join(scopes, "") + return c.formatTypeFuncs.WithScope(scope).FormatKindObject("", f.ParentObject.Name, false), nil + } + return c.formatType(f.TypeRef, strings.Join(scopes, ""), false) +} + +func (c *CommonFunctions) ToLowerCase(s string) string { + return fmt.Sprintf("%c%s", unicode.ToLower(rune(s[0])), s[1:]) +} + +func (c *CommonFunctions) ToUpperCase(s string) string { + return fmt.Sprintf("%c%s", unicode.ToUpper(rune(s[0])), s[1:]) +} + +func (c *CommonFunctions) IsListOfObject(t *introspection.TypeRef) bool { + return t.OfType.OfType.IsObject() +} + +func (c *CommonFunctions) IsListOfEnum(t *introspection.TypeRef) bool { + return t.OfType.OfType.IsEnum() +} + +func (c *CommonFunctions) GetArrayField(f *introspection.Field) ([]*introspection.Field, error) { + schema := GetSchema() + + fieldType := f.TypeRef + if !fieldType.IsOptional() { + fieldType = fieldType.OfType + } + if !fieldType.IsList() { + return nil, fmt.Errorf("field %s is not a list", f.Name) + } + fieldType = fieldType.OfType + if !fieldType.IsOptional() { + fieldType = fieldType.OfType + } + schemaType := schema.Types.Get(fieldType.Name) + if schemaType == nil { + return nil, fmt.Errorf("schema type %s is nil", fieldType.Name) + } + + var fields []*introspection.Field + var idField *introspection.Field + // Only include scalar fields for now + // TODO: include subtype too + for _, typeField := range schemaType.Fields { + if typeField.TypeRef.IsScalar() { + fields = append(fields, typeField) + } + // TODO: hack to fix requesting all fields from list of id-able objects, need better solution + if typeField.Name == "id" { + idField = typeField + break + } + } + if idField != nil { + return []*introspection.Field{idField}, nil + } + + return fields, nil +} + +// ConvertID returns true if the field returns an ID that should be +// converted into an object. +func (c *CommonFunctions) ConvertID(f introspection.Field) bool { + if f.Name == "id" { + return false + } + ref := f.TypeRef + if ref.Kind == introspection.TypeKindNonNull { + ref = ref.OfType + } + if ref.Kind != introspection.TypeKindScalar { + return false + } + // Check for @expectedType directive on the field. + // This replaces the old FooID suffix check. + expectedType := f.Directives.ExpectedType() + if expectedType != "" { + // Only convert if the expected type matches the parent object. + // This is for sync-like methods that return the object's own ID. + return expectedType == f.ParentObject.Name + } + // Legacy fallback: check FooID suffix pattern. + return ref.Name == f.ParentObject.Name+"ID" +} + +// FormatInputType formats a GraphQL type into the SDK language input +// +// Example: `String` -> `string` +func (c *CommonFunctions) FormatInputType(r *introspection.TypeRef, scopes ...string) (string, error) { + return c.formatType(r, strings.Join(scopes, ""), true) +} + +// FormatOutputType formats a GraphQL type into the SDK language output +// +// Example: `String` -> `string` +func (c *CommonFunctions) FormatOutputType(r *introspection.TypeRef, scopes ...string) (string, error) { + return c.formatType(r, strings.Join(scopes, ""), false) +} + +// formatType loops through the type reference to transform it into its SDK language. +func (c *CommonFunctions) formatType(r *introspection.TypeRef, scope string, input bool) (representation string, err error) { + ff := c.formatTypeFuncs.WithScope(scope) + + for ref := r; ref != nil; ref = ref.OfType { + switch ref.Kind { + case introspection.TypeKindList: + // Handle this special case with defer to format array at the end of + // the loop. + // Since an SDK needs to insert it at the end, other at the beginning. + defer func() { + representation = ff.FormatKindList(representation) + }() + case introspection.TypeKindScalar: + switch introspection.Scalar(ref.Name) { + case introspection.ScalarString: + return ff.FormatKindScalarString(representation), nil + case introspection.ScalarInt: + return ff.FormatKindScalarInt(representation), nil + case introspection.ScalarFloat: + return ff.FormatKindScalarFloat(representation), nil + case introspection.ScalarBoolean: + return ff.FormatKindScalarBoolean(representation), nil + default: + return ff.FormatKindScalarDefault(representation, ref.Name, input), nil + } + case introspection.TypeKindObject, introspection.TypeKindInterface: + return ff.FormatKindObject(representation, ref.Name, input), nil + case introspection.TypeKindInputObject: + return ff.FormatKindInputObject(representation, ref.Name, input), nil + case introspection.TypeKindEnum: + return ff.FormatKindEnum(representation, ref.Name), nil + } + } + + return "", fmt.Errorf("unexpected type kind %s", r.Kind) +} + +func (c *CommonFunctions) CheckVersionCompatibility(minVersion string) bool { + return semver.Compare(c.schemaVersion, minVersion) >= 0 +} diff --git a/helpers/codegen/generator/generator.go b/helpers/codegen/generator/generator.go new file mode 100644 index 0000000..285a50d --- /dev/null +++ b/helpers/codegen/generator/generator.go @@ -0,0 +1,57 @@ +package generator + +import ( + "context" + "errors" + "io/fs" + "os/exec" + + "codegen/introspection" +) + +var ErrUnknownSDKLang = errors.New("unknown sdk language") + +type SDKLang string + +const ( + SDKLangTypeScript SDKLang = "typescript" +) + +type Generator interface { + // GenerateModule runs codegen in a context of a module and returns a map of + // default filename to content for that file. + GenerateModule(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*GeneratedState, error) + + // GenerateClient runs codegen in a context of a standalone client and returns + // a map of default filename to content for that file. + GenerateClient(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*GeneratedState, error) + + // GenerateLibrary only generate the library bindings for the given schema. + GenerateLibrary(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*GeneratedState, error) + + // GenerateTypeDefs extract type definitions from a module and returns a map + // of default filename to content for that file. + GenerateTypeDefs(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*GeneratedState, error) + + // GenerateEntrypoint renders the static dispatch entrypoint file for a + // module from a previously-emitted typedef JSON (see + // `Config.EntrypointConfig`). + GenerateEntrypoint(ctx context.Context) (*GeneratedState, error) +} + +type GeneratedState struct { + // Overlay is the overlay filesystem that contains generated code to write + // over the output directory. + Overlay fs.FS + + // PostCommands are commands that need to be run after the codegen has + // finished. This is used for example to run `go mod tidy` after generating + // Go code. + PostCommands []*exec.Cmd + + // NeedRegenerate indicates that the code needs to be generated again. This + // can happen if the codegen spat out templates that depend on generated + // types. In that case the codegen needs to be run again with both the + // templates and the initially generated types available. + NeedRegenerate bool +} diff --git a/helpers/codegen/generator/overlay.go b/helpers/codegen/generator/overlay.go new file mode 100644 index 0000000..ed1f1c7 --- /dev/null +++ b/helpers/codegen/generator/overlay.go @@ -0,0 +1,49 @@ +package generator + +import ( + "context" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" +) + +func Overlay(ctx context.Context, overlay fs.FS, outputDir string) (rerr error) { + return fs.WalkDir(overlay, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if _, err := os.Stat(filepath.Join(outputDir, path)); err == nil { + slog.Info("creating directory [skipped]", "path", path) + return nil + } + slog.Info("creating directory", "path", path) + return os.MkdirAll(filepath.Join(outputDir, path), 0o755) + } + + var needsWrite bool + + newContent, err := fs.ReadFile(overlay, path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + outPath := filepath.Join(outputDir, path) + oldContent, err := os.ReadFile(outPath) + if err != nil { + needsWrite = true + } else { + needsWrite = string(oldContent) != string(newContent) + } + + if !needsWrite { + slog.Info("writing [skipped]", "path", path) + return nil + } + + slog.Info("writing", "path", path) + return os.WriteFile(outPath, newContent, 0o600) + }) +} diff --git a/helpers/codegen/generator/schema.go b/helpers/codegen/generator/schema.go new file mode 100644 index 0000000..9bb0013 --- /dev/null +++ b/helpers/codegen/generator/schema.go @@ -0,0 +1,24 @@ +package generator + +import ( + "codegen/introspection" +) + +var _schema *introspection.Schema + +func SetSchema(schema *introspection.Schema) { + _schema = schema +} + +func GetSchema() *introspection.Schema { + return _schema +} + +// SetSchemaParents sets all the parents for the fields. +func SetSchemaParents(schema *introspection.Schema) { + for _, t := range schema.Types { + for _, f := range t.Fields { + f.ParentObject = t + } + } +} diff --git a/helpers/codegen/generator/typescript/dep_split_test.go b/helpers/codegen/generator/typescript/dep_split_test.go new file mode 100644 index 0000000..df3e81e --- /dev/null +++ b/helpers/codegen/generator/typescript/dep_split_test.go @@ -0,0 +1,405 @@ +package typescriptgenerator + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "codegen/generator" + "codegen/generator/typescript/templates" + "codegen/introspection" +) + +// TestDepTemplate_RendersDepTypes renders the per-dep template against a small +// hand-crafted schema and asserts: +// - dep-owned scalar / class are emitted; +// - extendable types (Query/Client, Binding, Env) become +// `declare module` + prototype-assignment blocks; +// - the dep file imports BaseClient from the SDK runtime (not from +// client.gen.ts — which would create an ESM cycle). +func TestDepTemplate_RendersDepTypes(t *testing.T) { + helloModule := newSourceMapDirective("hello") + + full := &introspection.Schema{ + QueryType: struct { + Name string `json:"name,omitempty"` + }{Name: "Query"}, + Types: introspection.Types{ + // Extendable type with one dep-contributed field. + { + Kind: introspection.TypeKindObject, + Name: "Query", + Fields: []*introspection.Field{ + { + Name: "hello", + TypeRef: &introspection.TypeRef{ + Kind: introspection.TypeKindNonNull, + OfType: &introspection.TypeRef{ + Kind: introspection.TypeKindObject, + Name: "Hello", + }, + }, + Directives: introspection.Directives{helloModule}, + }, + }, + }, + // Dep-owned scalar. + { + Kind: introspection.TypeKindScalar, + Name: "HelloID", + Description: "Hello identifier.", + Directives: introspection.Directives{helloModule}, + }, + // Dep-owned regular class. + { + Kind: introspection.TypeKindObject, + Name: "Hello", + Directives: introspection.Directives{helloModule}, + Fields: []*introspection.Field{ + { + Name: "greet", + TypeRef: &introspection.TypeRef{ + Kind: introspection.TypeKindNonNull, + OfType: &introspection.TypeRef{ + Kind: introspection.TypeKindScalar, + Name: "String", + }, + }, + }, + }, + }, + // Core type. Not emitted in the dep file itself; included so + // CoreTypeNames returns it for the type-only import. + { + Kind: introspection.TypeKindObject, + Name: "Container", + }, + }, + } + generator.SetSchemaParents(full) + + depSchema := full.Include("hello") + generator.SetSchemaParents(depSchema) + + tmpl := templates.New("v0.21.0", full, "", generator.Config{ + Lang: generator.SDKLangTypeScript, + ModuleConfig: &generator.ModuleGeneratorConfig{ + ModuleName: "host", + ModuleSourcePath: ".", + }, + }) + + out := renderDepTemplate(t, tmpl, depSchema, "hello") + + // dep-owned scalar and class must appear. + require.Contains(t, out, "HelloID", + "dep-owned scalar must be emitted in the dep file") + require.Contains(t, out, "export class Hello extends BaseClient", + "dep-owned class must be emitted in the dep file") + + // extendable types (Query/Client, Binding, Env) must NOT be re-declared + // as classes — they're augmented via declare-module + prototype. + require.NotContains(t, out, "export class Client extends BaseClient", + "extendable type Client must not be re-rendered in the dep file") + + // dep-contributed extendable-type fields become augmentations. + require.Contains(t, out, `declare module "./client.gen.js"`, + "dep file must declare-module merge into client.gen.ts for IDE completion") + require.Contains(t, out, "interface Client {", + "dep-contributed Client methods must be declared via interface merging") + require.Contains(t, out, "Client.prototype.hello", + "dep-contributed methods must be attached via prototype assignment so they work at runtime") + require.Contains(t, out, "export function __applyHelloAugmentations", + "dep file must export the augmentation function client.gen.ts calls in its footer") + + // BaseClient comes from the SDK runtime, not from client.gen.ts. + require.Regexp(t, `import\s*\{\s*Context,\s*BaseClient`, out, + "BaseClient must be imported alongside Context from the runtime") + + // Other core types are imported type-only from client.gen.ts — type-only + // imports are erased at runtime, so no ESM cycle. + require.Contains(t, out, `import type {`) + require.Contains(t, out, `from "./client.gen.js"`) +} + +// TestHeaderTemplate_EmitsDependencyExports renders the header template against +// a schema containing two deps and asserts one import + `export *` per dep, +// with kebab-cased filenames, plus the BaseClient re-export. +func TestHeaderTemplate_EmitsDependencyExports(t *testing.T) { + full := &introspection.Schema{ + QueryType: struct { + Name string `json:"name,omitempty"` + }{Name: "Query"}, + Types: introspection.Types{ + newType("Hello", introspection.TypeKindObject, + introspection.Directives{newSourceMapDirective("hello")}), + newType("MyDep", introspection.TypeKindObject, + introspection.Directives{newSourceMapDirective("myDep")}), + }, + } + + tmpl := templates.New("v0.21.0", full, "", generator.Config{ + Lang: generator.SDKLangTypeScript, + ModuleConfig: &generator.ModuleGeneratorConfig{ + ModuleName: "host", + ModuleSourcePath: ".", + }, + }) + + var b bytes.Buffer + require.NoError(t, tmpl.ExecuteTemplate(&b, "header", nil)) + out := b.String() + + require.Contains(t, out, "export { BaseClient }", + "client.gen.ts must re-export BaseClient (the class lives in the SDK runtime to avoid an ESM cycle with dep files)") + require.Contains(t, out, `export * from "./hello.gen.js"`) + require.Contains(t, out, `export * from "./my-dep.gen.js"`, + "camelCase dep names must be kebab-cased in the filename") +} + +// TestGenerate_SplitsDependencyFiles exercises the full generate() flow and +// asserts the core file excludes the dep and a per-dep file is produced. +func TestGenerate_SplitsDependencyFiles(t *testing.T) { + helloModule := newSourceMapDirective("hello") + + schema := &introspection.Schema{ + QueryType: struct { + Name string `json:"name,omitempty"` + }{Name: "Query"}, + Types: introspection.Types{ + { + Kind: introspection.TypeKindObject, + Name: "Query", + Fields: []*introspection.Field{ + { + Name: "hello", + TypeRef: &introspection.TypeRef{ + Kind: introspection.TypeKindNonNull, + OfType: &introspection.TypeRef{ + Kind: introspection.TypeKindObject, + Name: "Hello", + }, + }, + Directives: introspection.Directives{helloModule}, + }, + }, + }, + { + Kind: introspection.TypeKindObject, + Name: "Hello", + Directives: introspection.Directives{helloModule}, + Fields: []*introspection.Field{ + { + Name: "id", + TypeRef: &introspection.TypeRef{ + Kind: introspection.TypeKindNonNull, + OfType: &introspection.TypeRef{ + Kind: introspection.TypeKindScalar, + Name: "HelloID", + }, + }, + }, + }, + }, + { + Kind: introspection.TypeKindScalar, + Name: "HelloID", + Directives: introspection.Directives{helloModule}, + }, + }, + } + + // The real pipeline (codegen.go) sets parents before generating; mirror + // that here since this test calls generate() directly. + generator.SetSchemaParents(schema) + + state, err := generate(generator.Config{ + Lang: generator.SDKLangTypeScript, + }, ClientGenFile, schema, "v0.21.0") + require.NoError(t, err) + + core := readOverlay(t, state, "client.gen.ts") + dep := readOverlay(t, state, "hello.gen.ts") + + // Core file does not re-declare the dep's class but does wire up the + // augmentation in its footer. + require.NotContains(t, core, "export class Hello extends BaseClient") + // Only Query (-> Client) is present in this schema, so the augmentation call + // passes just the extendable classes that exist. + require.Contains(t, core, "__applyHelloAugmentations({ Client })") + require.Contains(t, core, `export * from "./hello.gen.js"`) + + // Dep file carries the dep's own class. + require.Contains(t, dep, "export class Hello extends BaseClient") + require.Contains(t, dep, "export function __applyHelloAugmentations") +} + +// TestGenerate_KeepsOwnTypesInClient checks that only dependencies are split: +// the module being generated for keeps its own types in client.gen.ts. +func TestGenerate_KeepsOwnTypesInClient(t *testing.T) { + appModule := newSourceMapDirective("app") + depModule := newSourceMapDirective("dep") + strField := func(name string) *introspection.Field { + return &introspection.Field{ + Name: name, + TypeRef: &introspection.TypeRef{Kind: introspection.TypeKindNonNull, OfType: &introspection.TypeRef{Kind: introspection.TypeKindScalar, Name: "String"}}, + } + } + + schema := &introspection.Schema{ + QueryType: struct { + Name string `json:"name,omitempty"` + }{Name: "Query"}, + Types: introspection.Types{ + {Kind: introspection.TypeKindObject, Name: "App", Directives: introspection.Directives{appModule}, Fields: []*introspection.Field{strField("name")}}, + {Kind: introspection.TypeKindObject, Name: "Dep", Directives: introspection.Directives{depModule}, Fields: []*introspection.Field{strField("value")}}, + }, + } + generator.SetSchemaParents(schema) + + state, err := generate(generator.Config{ + Lang: generator.SDKLangTypeScript, + ModuleConfig: &generator.ModuleGeneratorConfig{ModuleName: "app"}, + }, ClientGenFile, schema, "v0.21.0") + require.NoError(t, err) + + core := readOverlay(t, state, "client.gen.ts") + depFile := readOverlay(t, state, "dep.gen.ts") + + // The module's own type stays in client.gen.ts (not split out). + require.Contains(t, core, "export class App extends BaseClient") + require.Contains(t, core, `export * from "./dep.gen.js"`) + require.NotContains(t, core, `export * from "./app.gen.js"`) + + // Only the dependency gets its own file. + require.Contains(t, depFile, "export class Dep extends BaseClient") + _, err = state.Overlay.Open("app.gen.ts") + require.Error(t, err, "the module's own types must not be split into app.gen.ts") +} + +// TestDepTemplate_CoreValuesAreValueImported guards the systemic gap where a +// dep method returning/accepting a core type emitted `new Container(ctx)` and +// `NetworkProtocolNameToValue(...)` against a type-only import (TS1361 + runtime +// ReferenceError). Core classes the bodies construct and the enum converters +// they call must be *value*-imported; pure signature types stay type-only. +func TestDepTemplate_CoreValuesAreValueImported(t *testing.T) { + helloModule := newSourceMapDirective("hello") + obj := func(ref string) *introspection.TypeRef { + return &introspection.TypeRef{ + Kind: introspection.TypeKindNonNull, + OfType: &introspection.TypeRef{Kind: introspection.TypeKindObject, Name: ref}, + } + } + enumRef := func(name string) *introspection.TypeRef { + return &introspection.TypeRef{ + Kind: introspection.TypeKindNonNull, + OfType: &introspection.TypeRef{Kind: introspection.TypeKindEnum, Name: name}, + } + } + + full := &introspection.Schema{ + QueryType: struct { + Name string `json:"name,omitempty"` + }{Name: "Query"}, + Types: introspection.Types{ + { + Kind: introspection.TypeKindObject, + Name: "Hello", + Directives: introspection.Directives{helloModule}, + Fields: []*introspection.Field{ + // Returns a core object -> `new Container(ctx)`. + {Name: "ctr", TypeRef: obj("Container")}, + // Takes a core object as an arg (signature type only) and + // returns a core enum -> `NetworkProtocolNameToValue(...)`. + { + Name: "proto", + TypeRef: enumRef("NetworkProtocol"), + Args: []introspection.InputValue{ + {Name: "dir", TypeRef: obj("Directory")}, + }, + }, + }, + }, + // Core types referenced above. + {Kind: introspection.TypeKindObject, Name: "Container", Fields: []*introspection.Field{{Name: "id", TypeRef: &introspection.TypeRef{Kind: introspection.TypeKindScalar, Name: "ContainerID"}}}}, + {Kind: introspection.TypeKindObject, Name: "Directory", Fields: []*introspection.Field{{Name: "id", TypeRef: &introspection.TypeRef{Kind: introspection.TypeKindScalar, Name: "DirectoryID"}}}}, + {Kind: introspection.TypeKindEnum, Name: "NetworkProtocol", EnumValues: []introspection.EnumValue{{Name: "TCP"}, {Name: "UDP"}}}, + }, + } + generator.SetSchemaParents(full) + depSchema := full.Include("hello") + generator.SetSchemaParents(depSchema) + + tmpl := templates.New("v0.21.0", full, "", generator.Config{ + Lang: generator.SDKLangTypeScript, + ModuleConfig: &generator.ModuleGeneratorConfig{ModuleName: "host", ModuleSourcePath: "."}, + }) + + out := renderDepTemplate(t, tmpl, depSchema, "hello") + + // Core classes the bodies construct are value-imported (not `import type`), + // and both arg-only (Directory) and constructed (Container) objects appear. + require.Regexp(t, `import \{[^}]*\bContainer\b[^}]*\} from "\./client\.gen\.js"`, out, + "constructed core class must be value-imported") + require.Regexp(t, `import \{[^}]*\bDirectory\b[^}]*\} from "\./client\.gen\.js"`, out, + "core class used as a signature type must still be importable") + // The enum converter called in the body is value-imported and the body uses it. + require.Regexp(t, `import \{[^}]*\bNetworkProtocolNameToValue\b[^}]*\} from "\./client\.gen\.js"`, out, + "enum converter must be value-imported") + require.Contains(t, out, "NetworkProtocolNameToValue(", "body must call the imported converter") + require.Contains(t, out, "return new Container(ctx)", "body must construct the core class") + + // The constructed class must not be pulled in as a type-only import (that + // would be erased at runtime). + require.NotRegexp(t, `import type \{[^}]*\bContainer\b`, out, + "a constructed class must not be type-only imported") +} + +func readOverlay(t *testing.T, state *generator.GeneratedState, name string) string { + t.Helper() + f, err := state.Overlay.Open(name) + require.NoError(t, err, "expected generated file %q", name) + defer f.Close() + var b bytes.Buffer + _, err = b.ReadFrom(f) + require.NoError(t, err) + return b.String() +} + +func renderDepTemplate(t *testing.T, tmpl *template.Template, schema *introspection.Schema, depName string) string { + t.Helper() + data := struct { + Schema *introspection.Schema + SchemaVersion string + Types []*introspection.Type + DepName string + }{ + Schema: schema, + SchemaVersion: "v0.21.0", + Types: schema.Types, + DepName: depName, + } + var b bytes.Buffer + require.NoError(t, tmpl.ExecuteTemplate(&b, "dep", data)) + return b.String() +} + +func newType(name string, kind introspection.TypeKind, directives introspection.Directives) *introspection.Type { + return &introspection.Type{ + Kind: kind, + Name: name, + Directives: directives, + } +} + +func newSourceMapDirective(moduleName string) *introspection.Directive { + v := `"` + moduleName + `"` + return &introspection.Directive{ + Name: "sourceMap", + Args: []*introspection.DirectiveArg{ + {Name: "module", Value: &v}, + }, + } +} diff --git a/helpers/codegen/generator/typescript/entrypoint.go b/helpers/codegen/generator/typescript/entrypoint.go new file mode 100644 index 0000000..90b323f --- /dev/null +++ b/helpers/codegen/generator/typescript/entrypoint.go @@ -0,0 +1,65 @@ +package typescriptgenerator + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + + "github.com/psanford/memfs" + + "codegen/generator" + "codegen/generator/typescript/templates" +) + +// DefaultEntrypointFile is the filename of the static dispatch entrypoint +// the runtime expects at the user's module root. +const DefaultEntrypointFile = "__dagger.entrypoint.ts" + +// GenerateEntrypoint renders the static dispatch `__dagger.entrypoint.ts` +// for the user's module from a previously-emitted typedef JSON. The path to +// that JSON, the module root, and other options come from +// `Config.EntrypointConfig`. +func (g *TypeScriptGenerator) GenerateEntrypoint(ctx context.Context) (*generator.GeneratedState, error) { + cfg := g.Config.EntrypointConfig + if cfg == nil { + return nil, fmt.Errorf("generate-entrypoint: missing EntrypointConfig") + } + if cfg.TypedefJSONPath == "" { + return nil, fmt.Errorf("generate-entrypoint: TypedefJSONPath is required") + } + + data, err := os.ReadFile(cfg.TypedefJSONPath) + if err != nil { + return nil, fmt.Errorf("read typedef json %q: %w", cfg.TypedefJSONPath, err) + } + + var module templates.TypedefModule + if err := json.Unmarshal(data, &module); err != nil { + return nil, fmt.Errorf("parse typedef json: %w", err) + } + + tmpl := templates.NewEntrypoint(&module, templates.EntrypointOptions{ + SDKImportPath: cfg.SDKImportPath, + ModuleRoot: cfg.ModuleRoot, + SourceDir: cfg.SourceDir, + }) + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "entrypoint", &module); err != nil { + return nil, fmt.Errorf("render entrypoint: %w", err) + } + + outFile := cfg.OutputFile + if outFile == "" { + outFile = DefaultEntrypointFile + } + + mfs := memfs.New() + if err := mfs.WriteFile(outFile, buf.Bytes(), 0o644); err != nil { + return nil, fmt.Errorf("write entrypoint to overlay: %w", err) + } + + return &generator.GeneratedState{Overlay: mfs}, nil +} diff --git a/helpers/codegen/generator/typescript/generator.go b/helpers/codegen/generator/typescript/generator.go new file mode 100644 index 0000000..0f037fb --- /dev/null +++ b/helpers/codegen/generator/typescript/generator.go @@ -0,0 +1,154 @@ +package typescriptgenerator + +import ( + "bytes" + "context" + "fmt" + "path/filepath" + "sort" + "text/template" + + "github.com/iancoleman/strcase" + "github.com/psanford/memfs" + + "codegen/generator" + "codegen/generator/typescript/templates" + "codegen/introspection" +) + +const ClientGenFile = "client.gen.ts" + +type TypeScriptGenerator struct { + Config generator.Config +} + +// Generate will generate the TypeScript SDK code and might modify the schema to reorder types in a alphanumeric fashion. +func (g *TypeScriptGenerator) GenerateModule(_ context.Context, schema *introspection.Schema, schemaVersion string) (*generator.GeneratedState, error) { + target := filepath.Join(g.Config.ModuleConfig.ModuleSourcePath, "sdk/src/api", ClientGenFile) + + return generate(g.Config, target, schema, schemaVersion) +} + +func (g *TypeScriptGenerator) GenerateClient(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*generator.GeneratedState, error) { + return generate(g.Config, ClientGenFile, schema, schemaVersion) +} + +func (g *TypeScriptGenerator) GenerateLibrary(ctx context.Context, schema *introspection.Schema, schemaVersion string) (*generator.GeneratedState, error) { + return generate(g.Config, ClientGenFile, schema, schemaVersion) +} + +func (g *TypeScriptGenerator) GenerateTypeDefs(_ context.Context, _ *introspection.Schema, _ string) (*generator.GeneratedState, error) { + return nil, fmt.Errorf("not implemented for %s SDK", generator.SDKLangTypeScript) +} + +func generate(config generator.Config, target string, schema *introspection.Schema, schemaVersion string) (*generator.GeneratedState, error) { + generator.SetSchema(schema) + + sort.SliceStable(schema.Types, func(i, j int) bool { + return schema.Types[i].Name < schema.Types[j].Name + }) + for _, v := range schema.Types { + sort.SliceStable(v.Fields, func(i, j int) bool { + in := v.Fields[i].Name + jn := v.Fields[j].Name + switch { + case in == "id" && jn == "id": + return false + case in == "id": + return true + case jn == "id": + return false + default: + return in < jn + } + }) + } + + // Split dependency-contributed types into their own .gen.ts files. + // The core file (client.gen.ts) is rendered from a schema with the + // dep-owned types removed; for the extendable types (Query/Binding/Env) + // the dep-contributed fields are dropped and re-attached as prototype + // augmentations in each per-dep file. With no dependencies this is a + // no-op and client.gen.ts retains its full-schema contents. + // Only split *dependencies* into their own files; the module being + // generated for keeps its own types in client.gen.ts. + selfModule := selfModuleName(config) + depNames := templates.DependencyModules(schema, selfModule) + coreSchema := schema + if len(depNames) > 0 { + coreSchema = schema.Exclude(depNames...) + } + + // The template funcs always get the full schema so the dependency- + // splitting helpers can enumerate deps regardless of which (possibly + // filtered) schema a given file is rendered from. + tmpl := templates.New(schemaVersion, schema, selfModule, config) + + mfs := memfs.New() + + // Render the core file (client.gen.ts) from the filtered core schema. + if err := renderTemplate(mfs, tmpl, "api", target, depFileData{ + Schema: coreSchema, + SchemaVersion: schemaVersion, + Types: coreSchema.Types, + }); err != nil { + return nil, err + } + + // Render one .gen.ts file per dependency. + for _, depName := range depNames { + depSchema := schema.Include(depName) + depTarget := filepath.Join(filepath.Dir(target), strcase.ToKebab(depName)+".gen.ts") + if err := renderTemplate(mfs, tmpl, "dep", depTarget, depFileData{ + Schema: depSchema, + SchemaVersion: schemaVersion, + Types: depSchema.Types, + DepName: depName, + }); err != nil { + return nil, fmt.Errorf("render dependency %q: %w", depName, err) + } + } + + return &generator.GeneratedState{ + Overlay: mfs, + }, nil +} + +// selfModuleName returns the name of the module the client is generated for +// (from the module or client config), or "" when generating outside a module +// (e.g. the SDK's own library client). +func selfModuleName(config generator.Config) string { + if config.ModuleConfig != nil { + return config.ModuleConfig.ModuleName + } + if config.ClientConfig != nil { + return config.ClientConfig.ModuleName + } + return "" +} + +// depFileData is the template "dot" for both the core "api" template and the +// per-dependency "dep" template. DepName is only set for dep files and is used +// to derive a unique augmentation function name. +type depFileData struct { + Schema *introspection.Schema + SchemaVersion string + Types []*introspection.Type + DepName string +} + +// renderTemplate executes the named template against data and writes the +// result to target inside mfs. +func renderTemplate(mfs *memfs.FS, tmpl *template.Template, name, target string, data depFileData) error { + var b bytes.Buffer + if err := tmpl.ExecuteTemplate(&b, name, data); err != nil { + return fmt.Errorf("render %q: %w", name, err) + } + if err := mfs.MkdirAll(filepath.Dir(target), 0700); err != nil { + return fmt.Errorf("failed to create target directory %s: %w", filepath.Dir(target), err) + } + if err := mfs.WriteFile(target, b.Bytes(), 0600); err != nil { + return fmt.Errorf("failed to write client file at %s: %w", target, err) + } + return nil +} diff --git a/helpers/codegen/generator/typescript/templates/entrypoint_functions.go b/helpers/codegen/generator/typescript/templates/entrypoint_functions.go new file mode 100644 index 0000000..c7ba731 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/entrypoint_functions.go @@ -0,0 +1,546 @@ +package templates + +import ( + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + "text/template" + "unicode" +) + +// EntrypointTemplateFuncs returns the template.FuncMap used by +// src/entrypoint/*.gtpl. All inline TS expression generation lives here so +// the templates only need to handle the structural layout (loops, +// conditionals, function bodies). +func EntrypointTemplateFuncs(module *TypedefModule, opts EntrypointOptions) template.FuncMap { + c := &entrypointFuncCtx{module: module, opts: opts} + return template.FuncMap{ + "jsString": jsString, + "pascalize": pascalize, + "coerceExpr": c.coerceExpr, + "serializeExpr": c.serializeExpr, + "renderTypeDef": c.renderTypeDef, + "renderArgCall": c.renderArgCall, + "renderFunctionExpr": c.renderFunctionExpr, + "classRuntimeRef": c.classRuntimeRef, + "classTypeRef": c.classTypeRef, + "isExportedClass": c.isExportedClass, + "coercedVarName": coercedVarName, + "needsTransform": needsTransform, + "isPrimitive": isPrimitive, + "isInteger": func(t *TypedefType) bool { return t != nil && t.Kind == KindInteger }, + "argCoercionLine": c.argCoercionLine, + "hasDefault": hasDefault, + "engineIfaceTypeName": c.engineIfaceTypeName, + "plannedImports": c.plannedImports, + "isVariadic": func(a *TypedefArgument) bool { return a.IsVariadic }, + "propFieldName": propFieldName, + "sortedKeysObjects": sortedObjectKeys, + "sortedKeysEnums": sortedEnumKeys, + "sortedKeysIfaces": sortedInterfaceKeys, + "sortedKeysMethods": sortedFunctionKeys, + "sortedKeysProps": sortedPropertyKeys, + "sortedKeysEnumValues": sortedEnumValueKeys, + "dict": func(values ...any) (map[string]any, error) { + if len(values)%2 != 0 { + return nil, fmt.Errorf("dict: odd number of values") + } + out := make(map[string]any, len(values)/2) + for i := 0; i < len(values); i += 2 { + k, ok := values[i].(string) + if !ok { + return nil, fmt.Errorf("dict: keys must be strings") + } + out[k] = values[i+1] + } + return out, nil + }, + } +} + +type entrypointFuncCtx struct { + module *TypedefModule + opts EntrypointOptions +} + +func (c *entrypointFuncCtx) isExportedClass(obj *TypedefObject) bool { + return obj.Kind == "class" && obj.IsExported +} + +// entrypointReservedBindings are the module-scope identifiers the generated +// entrypoint imports from the SDK or declares itself. A user class whose name +// matches one of these must be imported under an alias, otherwise it shadows +// the entrypoint's own binding (e.g. a user `Context` class vs the SDK's +// `Context`). +var entrypointReservedBindings = map[string]bool{ + // SDK imports + "Context": true, + "DaggerError": true, + "FunctionCachePolicy": true, + "TypeDefKind": true, + "connection": true, + "dag": true, + "getRegisteredClass": true, + "__dagger": true, + "telemetry": true, + // entrypoint-internal declarations + "__loadCoreObject": true, + "formatError": true, + "invoke": true, + "dispatch": true, + "register": true, +} + +// classBinding returns the local identifier an exported user class is imported +// under: the class name, unless it collides with a reserved entrypoint binding, +// in which case it's aliased. +func (c *entrypointFuncCtx) classBinding(name string) string { + if entrypointReservedBindings[name] { + return "__UserClass_" + name + } + return name +} + +func (c *entrypointFuncCtx) classRuntimeRef(obj *TypedefObject) string { + if obj.Kind == "class" && !obj.IsExported { + return "__cls_" + obj.Name + } + return c.classBinding(obj.Name) +} + +func (c *entrypointFuncCtx) classTypeRef(obj *TypedefObject) string { + if obj.Kind == "class" && obj.IsExported { + return c.classBinding(obj.Name) + } + return "any" +} + +func (c *entrypointFuncCtx) coerceExpr(expr string, t *TypedefType) string { + if t == nil { + return expr + } + switch t.Kind { + case KindString, KindInteger, KindFloat, KindBoolean, KindScalar, KindVoid: + return expr + case KindList: + return fmt.Sprintf("(%s as any[]).map((__v) => %s)", expr, c.coerceExpr("__v", t.TypeDef)) + case KindObject: + if _, ok := c.module.Objects[t.Name]; ok { + return fmt.Sprintf("rebuild%s(%s)", t.Name, expr) + } + // Core/dependency object: the engine hands us an ID string; load a + // typed client from it via node(id:) (unified-ID API, post-#12041). + return fmt.Sprintf("__loadCoreObject(%s, %s)", expr, jsString(t.Name)) + case KindEnum: + e, ok := c.module.Enums[t.Name] + if !ok { + return expr + } + entries := make([]string, 0, len(e.Values)) + for _, vName := range sortedEnumValueKeys(e.Values) { + v := e.Values[vName] + entries = append(entries, fmt.Sprintf("%s: %s", jsString(v.Name), jsString(v.Value))) + } + return fmt.Sprintf("({ %s } as Record)[%s] ?? %s", strings.Join(entries, ", "), expr, expr) + case KindInterface: + if _, ok := c.module.Interfaces[t.Name]; ok { + return fmt.Sprintf("__Iface_%s.fromID(%s)", t.Name, expr) + } + return expr + default: + return expr + } +} + +func (c *entrypointFuncCtx) serializeExpr(expr string, t *TypedefType) string { + if t == nil { + return expr + } + switch t.Kind { + case KindString, KindInteger, KindFloat, KindBoolean, KindScalar, KindVoid: + return expr + case KindList: + return fmt.Sprintf("await Promise.all((%s as any[]).map(async (__v) => %s))", expr, c.serializeExpr("__v", t.TypeDef)) + case KindObject: + if _, ok := c.module.Objects[t.Name]; ok { + return fmt.Sprintf("await serialize%s(%s)", t.Name, expr) + } + return fmt.Sprintf("await (%s).id()", expr) + case KindEnum: + e, ok := c.module.Enums[t.Name] + if !ok { + return expr + } + entries := make([]string, 0, len(e.Values)) + for _, vName := range sortedEnumValueKeys(e.Values) { + v := e.Values[vName] + entries = append(entries, fmt.Sprintf("%s: %s", jsString(v.Value), jsString(v.Name))) + } + return fmt.Sprintf("({ %s } as Record)[%s] ?? %s", strings.Join(entries, ", "), expr, expr) + case KindInterface: + return fmt.Sprintf("await (%s).id()", expr) + default: + return expr + } +} + +func (c *entrypointFuncCtx) renderTypeDef(t *TypedefType) string { + if t == nil { + return "dag.typeDef()" + } + switch t.Kind { + case KindScalar: + return fmt.Sprintf("dag.typeDef().withScalar(%s)", jsString(t.Name)) + case KindObject: + return fmt.Sprintf("dag.typeDef().withObject(%s)", jsString(t.Name)) + case KindList: + return fmt.Sprintf("dag.typeDef().withListOf(%s)", c.renderTypeDef(t.TypeDef)) + case KindVoid: + return "dag.typeDef().withKind(TypeDefKind.VoidKind).withOptional(true)" + case KindEnum: + return fmt.Sprintf("dag.typeDef().withEnum(%s)", jsString(t.Name)) + case KindInterface: + return fmt.Sprintf("dag.typeDef().withInterface(%s)", jsString(t.Name)) + case KindString: + return "dag.typeDef().withKind(TypeDefKind.StringKind)" + case KindInteger: + return "dag.typeDef().withKind(TypeDefKind.IntegerKind)" + case KindFloat: + return "dag.typeDef().withKind(TypeDefKind.FloatKind)" + case KindBoolean: + return "dag.typeDef().withKind(TypeDefKind.BooleanKind)" + default: + return "dag.typeDef()" + } +} + +func (c *entrypointFuncCtx) renderArgCall(arg *TypedefArgument) string { + opts := map[string]string{} + if arg.Description != "" { + opts["description"] = jsString(arg.Description) + } + if arg.Deprecated != "" { + opts["deprecated"] = jsString(arg.Deprecated) + } + if arg.DefaultPath != "" { + opts["defaultPath"] = jsString(arg.DefaultPath) + } + if arg.DefaultAddress != "" { + opts["defaultAddress"] = jsString(arg.DefaultAddress) + } + if len(arg.Ignore) > 0 { + ignores := make([]string, len(arg.Ignore)) + for i, p := range arg.Ignore { + ignores[i] = jsString(p) + } + opts["ignore"] = "[" + strings.Join(ignores, ", ") + "]" + } + td := c.renderTypeDef(arg.Type) + if arg.IsOptional { + td += ".withOptional(true)" + } + if hasDefault(arg) { + dv, ok := c.resolveDefaultValue(arg) + if !ok { + if !arg.IsOptional { + td += ".withOptional(true)" + } + } else { + b, _ := json.Marshal(dv) + opts["defaultValue"] = fmt.Sprintf("JSON.stringify(%s) as string & { __JSON: never }", string(b)) + } + } + return fmt.Sprintf(".withArg(%s, %s%s)", jsString(arg.Name), td, optsLit(opts)) +} + +func (c *entrypointFuncCtx) resolveDefaultValue(arg *TypedefArgument) (any, bool) { + if !isPrimitive(arg.Type) { + return nil, false + } + var v any + if err := json.Unmarshal(arg.DefaultValue, &v); err != nil { + return nil, false + } + if arg.Type.Kind != KindEnum { + return v, true + } + e, ok := c.module.Enums[arg.Type.Name] + if !ok { + return v, true + } + for _, name := range sortedEnumValueKeys(e.Values) { + val := e.Values[name] + if val.Value == fmt.Sprintf("%v", v) { + return val.Name, true + } + } + return nil, false +} + +func (c *entrypointFuncCtx) renderFunctionExpr(fn *TypedefFunction) string { + fnName := fn.Alias + if fnName == "" { + fnName = fn.Name + } + parts := []string{fmt.Sprintf("dag.function_(%s, %s)", jsString(fnName), c.renderTypeDef(fn.ReturnType))} + if fn.Description != "" { + parts = append(parts, fmt.Sprintf(".withDescription(%s)", jsString(fn.Description))) + } + for _, arg := range fn.Arguments { + parts = append(parts, c.renderArgCall(arg)) + } + switch fn.Cache { + case "never": + parts = append(parts, ".withCachePolicy(FunctionCachePolicy.Never)") + case "session": + parts = append(parts, ".withCachePolicy(FunctionCachePolicy.PerSession)") + case "", "default": + // nothing + default: + parts = append(parts, fmt.Sprintf(".withCachePolicy(FunctionCachePolicy.Default, { timeToLive: %s })", jsString(fn.Cache))) + } + if fn.Deprecated != "" { + parts = append(parts, fmt.Sprintf(".withDeprecated({ reason: %s })", jsString(fn.Deprecated))) + } + if fn.IsCheck { + parts = append(parts, ".withCheck()") + } + if fn.IsGenerator { + parts = append(parts, ".withGenerator()") + } + if fn.IsUp { + parts = append(parts, ".withUp()") + } + return strings.Join(parts, "") +} + +// argCoercionLine emits a single `const __arg_X = ...` declaration coercing +// the engine-supplied JSON value into a runtime value the user method +// expects (node(id:) load for core IDables, rebuilder for module objects, +// enum map, iface wrap, etc.). +func (c *entrypointFuncCtx) argCoercionLine(arg *TypedefArgument) string { + v := coercedVarName(arg) + access := fmt.Sprintf("args[%s]", jsString(arg.Name)) + if hasDefault(arg) && isPrimitive(arg.Type) { + return fmt.Sprintf("const %s = %s", v, c.coerceExpr(access, arg.Type)) + } + // An omitted arg has no key in inputArgs, so `access` is undefined. + // Normalize that (and an explicit null) to a sensible default matching the + // runtime loader contract: + // - a variadic arg defaults to an empty array so the `...` spread is safe + // - a nullable arg with no default becomes null (user code typed + // `T | null` must receive null, not undefined) + fallback := access + switch { + case arg.IsVariadic: + fallback = "[]" + case arg.IsNullable && !hasDefault(arg): + fallback = "null" + } + return fmt.Sprintf( + "const %s = %s === undefined || %s === null ? %s : %s", + v, access, access, fallback, c.coerceExpr(access, arg.Type), + ) +} + +// engineIfaceTypeName returns "" — the namespaced GraphQL type +// name under which a module interface is registered in the schema. The iface +// wrapper uses it with node(id:) (via Context.selectNode) to instantiate from +// its engine-side ID. +func (c *entrypointFuncCtx) engineIfaceTypeName(iface *TypedefInterface) string { + return pascalize(c.module.Name) + iface.Name +} + +// plannedImports returns an ordered slice of import lines to emit. Encodes +// the named/namespace/side-effect plan for the imports template. +func (c *entrypointFuncCtx) plannedImports() []importLine { + sdk := c.opts.SDKImportPath + if sdk == "" { + sdk = "@dagger.io/dagger" + } + var lines []importLine + lines = append(lines, importLine{ + From: sdk, + Names: []string{"Context", "Error as DaggerError", "FunctionCachePolicy", "TypeDefKind", "connection", "dag", "getRegisteredClass"}, + }) + // Namespace import of the generated client so __loadCoreObject can look up + // core/dependency object classes by name when loading them from an ID. + lines = append(lines, importLine{From: sdk, Namespace: "* as __dagger"}) + lines = append(lines, importLine{From: sdk + "/telemetry", Namespace: "* as telemetry"}) + + // Group user imports by file path, deduping side-effect-only files. + groups := map[string]*importLine{} + order := []string{} + for _, name := range sortedObjectKeys(c.module.Objects) { + obj := c.module.Objects[name] + if obj.Kind != "class" { + continue + } + path, err := userImportPath(obj, c.opts) + if err != nil { + continue + } + g, ok := groups[path] + if !ok { + g = &importLine{From: path} + groups[path] = g + order = append(order, path) + } + switch { + case obj.IsDefaultExport: + // A default import binds whatever local name we choose, so just use + // the (possibly aliased) binding directly. + g.Default = c.classBinding(obj.Name) + case obj.IsExported: + if binding := c.classBinding(obj.Name); binding != obj.Name { + g.Names = append(g.Names, obj.Name+" as "+binding) + } else { + g.Names = append(g.Names, obj.Name) + } + default: + g.SideEffect = true + } + } + for _, p := range order { + g := groups[p] + sort.Strings(g.Names) + lines = append(lines, *g) + } + return lines +} + +type importLine struct { + From string + Names []string + Default string // default import binding (e.g. `export default class Foo`) + Namespace string + SideEffect bool +} + +func userImportPath(obj *TypedefObject, opts EntrypointOptions) (string, error) { + if obj.Location != nil && obj.Location.Filepath != "" { + fp := obj.Location.Filepath + var rel string + if opts.ModuleRoot != "" && filepath.IsAbs(fp) && filepath.IsAbs(opts.ModuleRoot) { + r, err := filepath.Rel(opts.ModuleRoot, fp) + if err != nil { + return "", fmt.Errorf("relative import path for %s: %w", obj.Name, err) + } + rel = r + } else { + rel = fp + } + for _, ext := range []string{".tsx", ".mts", ".ts"} { + if strings.HasSuffix(rel, ext) { + rel = strings.TrimSuffix(rel, ext) + break + } + } + rel = filepath.ToSlash(rel) + if !strings.HasPrefix(rel, ".") { + rel = "./" + rel + } + return rel, nil + } + src := opts.SourceDir + if src == "" { + src = "src" + } + return "./" + src + "/index", nil +} + +// ---- shared helpers ------------------------------------------------------- + +func needsTransform(t *TypedefType) bool { + if t == nil { + return false + } + switch t.Kind { + case KindString, KindInteger, KindFloat, KindBoolean, KindVoid: + return false + default: + return true + } +} + +func isPrimitive(t *TypedefType) bool { + if t == nil { + return false + } + switch t.Kind { + case KindBoolean, KindInteger, KindString, KindFloat, KindEnum: + return true + } + return false +} + +func hasDefault(arg *TypedefArgument) bool { + return len(arg.DefaultValue) > 0 && string(arg.DefaultValue) != "null" +} + +func coercedVarName(arg *TypedefArgument) string { + return "__arg_" + arg.Name +} + +func propFieldName(prop *TypedefProperty) string { + if prop.Alias != "" { + return prop.Alias + } + return prop.Name +} + +func jsString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func optsLit(opts map[string]string) string { + if len(opts) == 0 { + return "" + } + keys := sortedKeys(opts) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s: %s", k, opts[k])) + } + return ", { " + strings.Join(parts, ", ") + " }" +} + +func pascalize(s string) string { + if s == "" { + return "" + } + splitOn := func(r rune) bool { + return r == '_' || r == '-' || unicode.IsSpace(r) + } + segs := strings.FieldsFunc(s, splitOn) + for i, seg := range segs { + if seg == "" { + continue + } + runes := []rune(seg) + runes[0] = unicode.ToUpper(runes[0]) + segs[i] = string(runes) + } + return strings.Join(segs, "") +} + +// ---- sorted helpers (deterministic output) -------------------------------- + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func sortedObjectKeys(m map[string]*TypedefObject) []string { return sortedKeys(m) } +func sortedEnumKeys(m map[string]*TypedefEnum) []string { return sortedKeys(m) } +func sortedEnumValueKeys(m map[string]*TypedefEnumValue) []string { return sortedKeys(m) } +func sortedFunctionKeys(m map[string]*TypedefFunction) []string { return sortedKeys(m) } +func sortedPropertyKeys(m map[string]*TypedefProperty) []string { return sortedKeys(m) } +func sortedInterfaceKeys(m map[string]*TypedefInterface) []string { return sortedKeys(m) } diff --git a/helpers/codegen/generator/typescript/templates/entrypoint_typedef.go b/helpers/codegen/generator/typescript/templates/entrypoint_typedef.go new file mode 100644 index 0000000..4145b7e --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/entrypoint_typedef.go @@ -0,0 +1,120 @@ +package templates + +import ( + "encoding/json" +) + +// TypedefModule is the JSON shape produced by the TypeScript SDK +// introspector (`sdk/typescript/src/module/introspector/typedef_json.ts`). +// It is consumed by the entrypoint emitter (entrypoint.go) to render the +// static dispatch `__dagger.entrypoint.ts` file. +type TypedefModule struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Objects map[string]*TypedefObject `json:"objects"` + Enums map[string]*TypedefEnum `json:"enums"` + Interfaces map[string]*TypedefInterface `json:"interfaces"` +} + +type TypedefObject struct { + Name string `json:"name"` + Kind string `json:"kind"` // "class" | "object" + IsExported bool `json:"isExported"` + IsDefaultExport bool `json:"isDefaultExport"` + Description string `json:"description"` + Deprecated string `json:"deprecated,omitempty"` + Location *TypedefLocation `json:"location,omitempty"` + Constructor *TypedefConstructor `json:"constructor,omitempty"` + Methods map[string]*TypedefFunction `json:"methods"` + Properties map[string]*TypedefProperty `json:"properties"` +} + +type TypedefConstructor struct { + Name string `json:"name"` + Arguments []*TypedefArgument `json:"arguments"` +} + +type TypedefFunction struct { + Name string `json:"name"` + Alias string `json:"alias,omitempty"` + Cache string `json:"cache,omitempty"` + Description string `json:"description"` + Deprecated string `json:"deprecated,omitempty"` + IsCheck bool `json:"isCheck"` + IsGenerator bool `json:"isGenerator"` + IsUp bool `json:"isUp"` + ReturnType *TypedefType `json:"returnType,omitempty"` + Arguments []*TypedefArgument `json:"arguments"` +} + +type TypedefArgument struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Deprecated string `json:"deprecated,omitempty"` + Type *TypedefType `json:"type,omitempty"` + IsVariadic bool `json:"isVariadic"` + IsNullable bool `json:"isNullable"` + IsOptional bool `json:"isOptional"` + DefaultValue json.RawMessage `json:"defaultValue,omitempty"` + DefaultPath string `json:"defaultPath,omitempty"` + DefaultAddress string `json:"defaultAddress,omitempty"` + Ignore []string `json:"ignore,omitempty"` +} + +type TypedefProperty struct { + Name string `json:"name"` + Alias string `json:"alias,omitempty"` + Description string `json:"description,omitempty"` + Deprecated string `json:"deprecated,omitempty"` + IsExposed bool `json:"isExposed"` + Type *TypedefType `json:"type,omitempty"` +} + +type TypedefEnum struct { + Name string `json:"name"` + Description string `json:"description"` + Values map[string]*TypedefEnumValue `json:"values"` +} + +type TypedefEnumValue struct { + Name string `json:"name"` + Value string `json:"value"` + Description string `json:"description"` + Deprecated string `json:"deprecated,omitempty"` +} + +type TypedefInterface struct { + Name string `json:"name"` + Description string `json:"description"` + Functions map[string]*TypedefFunction `json:"functions"` +} + +// TypedefType is the discriminated typedef payload — `kind` is one of the +// "*_KIND" string constants from the Dagger GraphQL schema. `Name` is set +// for OBJECT/ENUM/INTERFACE/SCALAR; `TypeDef` is set for LIST. +type TypedefType struct { + Kind string `json:"kind"` + Name string `json:"name,omitempty"` + TypeDef *TypedefType `json:"typeDef,omitempty"` +} + +type TypedefLocation struct { + Filepath string `json:"filepath"` + Line int `json:"line"` + Column int `json:"column"` +} + +// TypeDef kind constants — match the Dagger GraphQL schema's TypeDefKind enum. +const ( + KindString = "STRING_KIND" + KindInteger = "INTEGER_KIND" + KindFloat = "FLOAT_KIND" + KindBoolean = "BOOLEAN_KIND" + KindVoid = "VOID_KIND" + KindList = "LIST_KIND" + KindObject = "OBJECT_KIND" + KindEnum = "ENUM_KIND" + KindInterface = "INTERFACE_KIND" + KindScalar = "SCALAR_KIND" + KindInput = "INPUT_KIND" +) diff --git a/helpers/codegen/generator/typescript/templates/format.go b/helpers/codegen/generator/typescript/templates/format.go new file mode 100644 index 0000000..ffc69be --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/format.go @@ -0,0 +1,79 @@ +package templates + +import ( + "strings" + + "codegen/generator" +) + +// FormatTypeFunc is an implementation of generator.FormatTypeFuncs interface +// to format GraphQL type into Typescript. +type FormatTypeFunc struct { + scope string + formatNameFunc func(s string) string +} + +func (f *FormatTypeFunc) WithScope(scope string) generator.FormatTypeFuncs { + if scope != "" { + scope += "." + } + clone := *f + clone.scope = scope + return &clone +} + +func (f *FormatTypeFunc) FormatKindList(representation string) string { + representation += "[]" + return representation +} + +func (f *FormatTypeFunc) FormatKindScalarString(representation string) string { + representation += "string" + return representation +} + +func (f *FormatTypeFunc) FormatKindScalarInt(representation string) string { + representation += "number" + return representation +} + +func (f *FormatTypeFunc) FormatKindScalarFloat(representation string) string { + representation += "float" + return representation +} + +func (f *FormatTypeFunc) FormatKindScalarBoolean(representation string) string { + representation += "boolean" + return representation +} + +func (f *FormatTypeFunc) FormatKindScalarDefault(representation string, refName string, input bool) string { + if obj, rest, ok := strings.Cut(refName, "ID"); input && ok && rest == "" && obj != "" { + // map e.g. FooID to Foo + representation += f.scope + f.formatNameFunc(obj) + } else { + representation += f.scope + refName + } + + return representation +} + +func (f *FormatTypeFunc) FormatKindObject(representation string, refName string, input bool) string { + name := refName + if name == generator.QueryStructName { + name = generator.QueryStructClientName + } + + representation += f.scope + f.formatNameFunc(name) + return representation +} + +func (f *FormatTypeFunc) FormatKindInputObject(representation string, refName string, input bool) string { + representation += f.scope + f.formatNameFunc(refName) + return representation +} + +func (f *FormatTypeFunc) FormatKindEnum(representation string, refName string) string { + representation += f.scope + refName + return representation +} diff --git a/helpers/codegen/generator/typescript/templates/functions.go b/helpers/codegen/generator/typescript/templates/functions.go new file mode 100644 index 0000000..5fe9e96 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/functions.go @@ -0,0 +1,1062 @@ +package templates + +import ( + "cmp" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + "text/template" + + "golang.org/x/mod/semver" + + "github.com/iancoleman/strcase" + + "codegen/generator" + "codegen/introspection" +) + +func TypescriptTemplateFuncs( + schemaVersion string, + fullSchema *introspection.Schema, + selfModule string, + cfg generator.Config, +) template.FuncMap { + return typescriptTemplateFuncs{ + cfg: cfg, + schemaVersion: schemaVersion, + fullSchema: fullSchema, + selfModule: selfModule, + memo: &templateMemo{}, + }.FuncMap() +} + +// templateMemo caches results that are constant across every file rendered from +// one schema. It is held by pointer so the cache survives the value-copies the +// template engine makes of the funcs receiver. Rendering is sequential, so no +// locking is needed. +type templateMemo struct { + depExports []DependencyExport + depExportsSet bool +} + +type typescriptTemplateFuncs struct { + schemaVersion string + cfg generator.Config + memo *templateMemo + + // fullSchema is the complete, unfiltered schema (all dependency types + // included). The per-file render data may carry a filtered schema (the + // core schema for client.gen.ts, or a single dep's schema), so the + // dependency-splitting helpers consult fullSchema to enumerate deps and + // to decide which types belong to client.gen.ts vs. a per-dep file. + fullSchema *introspection.Schema + + // selfModule is the name of the module the client is being generated for. + // Only *dependencies* are split into their own .gen.ts files; the + // module's own types stay in client.gen.ts, so self is excluded from the + // dependency enumeration. + selfModule string +} + +// DependencyModules returns the schema's dependency module names with the +// module being generated for (self) removed: only dependencies are split into +// their own files. Names are compared kebab-cased to tolerate casing/separator +// differences between sourceMap module names and the configured name. This is +// the single source of truth shared by the generator (which filters the schema) +// and the templates (which enumerate the per-dep files). +func DependencyModules(schema *introspection.Schema, self string) []string { + if schema == nil { + return nil + } + all := schema.DependencyNames() + out := make([]string, 0, len(all)) + for _, name := range all { + if isSameModule(name, self) { + continue + } + out = append(out, name) + } + return out +} + +// dependencyNames returns the modules whose types are split into their own +// files: every module in the schema except the one being generated for. +func (funcs typescriptTemplateFuncs) dependencyNames() []string { + return DependencyModules(funcs.fullSchema, funcs.selfModule) +} + +// isSameModule compares two module names tolerant of casing/separator +// differences (sourceMap module names vs. the configured module name). +func isSameModule(a, b string) bool { + if a == "" || b == "" { + return false + } + return strcase.ToKebab(a) == strcase.ToKebab(b) +} + +func (funcs typescriptTemplateFuncs) FuncMap() template.FuncMap { + formatTypeFunc := &FormatTypeFunc{ + formatNameFunc: funcs.formatName, + } + commonFunc := generator.NewCommonFunctions(funcs.schemaVersion, formatTypeFunc) + return template.FuncMap{ + "FormatFieldOutputType": funcs.formatFieldOutputType(commonFunc), + "FormatFieldReturnType": funcs.formatFieldReturnType(commonFunc), + "CommentToLines": funcs.commentToLines, + "FormatDeprecation": funcs.formatDeprecation, + "FormatExperimental": funcs.formatExperimental, + "FormatReturnType": commonFunc.FormatReturnType, + "FormatInputType": funcs.formatInputType(commonFunc), + "FormatOutputType": commonFunc.FormatOutputType, + "FormatEnum": funcs.formatEnum, + "FormatName": funcs.formatName, + "QueryToClient": funcs.queryToClient, + "GetOptionalArgs": funcs.getOptionalArgs, + "GetRequiredArgs": funcs.getRequiredArgs, + "HasPrefix": strings.HasPrefix, + "PascalCase": funcs.pascalCase, + "IsArgOptional": funcs.isArgOptional, + "IsCustomScalar": funcs.isCustomScalar, + "IsEnum": funcs.isEnum, + "IsKeyword": funcs.isKeyword, + "ArgsHaveDescription": funcs.argsHaveDescription, + "SortInputFields": funcs.sortInputFields, + "SortEnumFields": funcs.sortEnumFields, + "ExtractEnumValue": funcs.extractEnumValue, + "GroupEnumByValue": funcs.groupEnumByValue, + "GetInputEnumValueType": funcs.getInputEnumValueType, + "Solve": funcs.solve, + "Subtract": funcs.subtract, + "ConvertID": commonFunc.ConvertID, + "IsSelfChainable": commonFunc.IsSelfChainable, + "IsListOfObject": commonFunc.IsListOfObject, + "IsListOfInterface": funcs.isListOfInterface, + "IsListOfEnum": commonFunc.IsListOfEnum, + "GetArrayField": commonFunc.GetArrayField, + "ToLowerCase": commonFunc.ToLowerCase, + "ToUpperCase": commonFunc.ToUpperCase, + "ToSingleType": funcs.toSingleType, + "GetEnumValues": funcs.getEnumValues, + "IsInterface": funcs.isInterface, + "CheckVersionCompatibility": commonFunc.CheckVersionCompatibility, + "ModuleRelPath": funcs.moduleRelPath, + "FormatProtected": funcs.formatProtected, + "IsClientOnly": funcs.isClientOnly, + "Dependencies": funcs.Dependencies, + "HasLocalDependencies": funcs.HasLocalDependencies, + "IsBundle": funcs.isBundle, + "LegacyTypeScriptSDKCompat": funcs.legacyTypeScriptSDKCompat, + "LegacyIDableTypes": funcs.legacyIDableTypes, + "LegacyIDName": funcs.legacyIDName, + "LegacyLoadFromIDName": funcs.legacyLoadFromIDName, + // Dependency splitting: render each dependency's types into its own + // .gen.ts file plus prototype augmentations on the extendable + // types (Client/Binding/Env). + "DependencyFiles": funcs.dependencyFiles, + "DependencyExports": funcs.dependencyExports, + "DepFileName": funcs.depFileName, + "CoreTypeNames": funcs.coreTypeNames, + "CoreValueNames": funcs.coreValueNames, + "ExtendableClassNames": funcs.extendableClassNames, + "IsExtendableType": funcs.isExtendableType, + "AugmentFnName": augmentFnName, + "Augmentation": augmentation, + } +} + +// legacyTypeScriptSDKCompatCutoverVersion is the first engine version whose +// TypeScript SDK surface is generated with unified ID-only re-entry and +// first-class interface client types. The -0 prerelease floor intentionally +// treats v0.21.0 dev builds as post-cutover while keeping v0.20.x modules in +// legacy mode. +const legacyTypeScriptSDKCompatCutoverVersion = "v0.21.0-0" + +func (funcs typescriptTemplateFuncs) legacyTypeScriptSDKCompat() bool { + if funcs.schemaVersion == "" || !semver.IsValid(funcs.schemaVersion) { + return false + } + return semver.Compare(funcs.schemaVersion, legacyTypeScriptSDKCompatCutoverVersion) < 0 +} + +// isInterface checks if the type is a GraphQL interface. +func (funcs typescriptTemplateFuncs) isInterface(t *introspection.Type) bool { + return t.Kind == introspection.TypeKindInterface +} + +// formatInputType returns a function that formats input values. +func (funcs typescriptTemplateFuncs) formatInputType( + commonFunc *generator.CommonFunctions, +) func(arg introspection.InputValue, scopes ...string) (string, error) { + return func(arg introspection.InputValue, scopes ...string) (string, error) { + if expectedType := arg.Directives.ExpectedType(); expectedType != "" { + if arg.Name == "id" && funcs.legacyTypeScriptSDKCompat() && expectedType != "Node" && !strings.HasPrefix(expectedType, "_") { + representation := funcs.scoped(scopes...) + funcs.legacyIDName(expectedType) + if arg.TypeRef != nil && arg.TypeRef.IsList() { + representation += "[]" + } + return representation, nil + } + representation := funcs.scoped(scopes...) + funcs.formatName(expectedType) + if arg.TypeRef != nil && arg.TypeRef.IsList() { + representation += "[]" + } + return representation, nil + } + if arg.Name == "id" { + return commonFunc.FormatOutputType(arg.TypeRef, scopes...) + } + return commonFunc.FormatInputType(arg.TypeRef, scopes...) + } +} + +func (funcs typescriptTemplateFuncs) isListOfInterface(t *introspection.TypeRef) bool { + if t == nil || !t.IsList() { + return false + } + for ref := t; ref != nil; ref = ref.OfType { + switch ref.Kind { + case introspection.TypeKindNonNull, introspection.TypeKindList: + continue + default: + return ref.Kind == introspection.TypeKindInterface + } + } + return false +} + +// formatFieldOutputType returns the raw response value type for a field. Legacy +// ID fields use old FooID aliases so generated caches/constructors match the +// pre-cutover public surface. +func (funcs typescriptTemplateFuncs) formatFieldOutputType( + commonFunc *generator.CommonFunctions, +) func(field introspection.Field, scopes ...string) (string, error) { + return func(field introspection.Field, scopes ...string) (string, error) { + if funcs.legacyTypeScriptSDKCompat() && field.TypeRef.IsScalar() { + if expectedType := funcs.fieldExpectedIDType(field); expectedType != "" { + return funcs.scoped(scopes...) + funcs.legacyIDName(expectedType), nil + } + } + return commonFunc.FormatOutputType(field.TypeRef, scopes...) + } +} + +// formatFieldReturnType returns the public method return type for a field. ID +// fields that are object re-entry points remain converted to their object type; +// plain ID fields use legacy FooID aliases only in legacy mode. +func (funcs typescriptTemplateFuncs) formatFieldReturnType( + commonFunc *generator.CommonFunctions, +) func(field introspection.Field, scopes ...string) (string, error) { + return func(field introspection.Field, scopes ...string) (string, error) { + if commonFunc.ConvertID(field) { + return commonFunc.FormatReturnType(field, scopes...) + } + if funcs.legacyTypeScriptSDKCompat() && field.TypeRef.IsScalar() { + if expectedType := funcs.fieldExpectedIDType(field); expectedType != "" { + return funcs.scoped(scopes...) + funcs.legacyIDName(expectedType), nil + } + } + return commonFunc.FormatReturnType(field, scopes...) + } +} + +func (funcs typescriptTemplateFuncs) scoped(scopes ...string) string { + scope := strings.Join(scopes, "") + if scope != "" { + scope += "." + } + return scope +} + +func (funcs typescriptTemplateFuncs) fieldExpectedIDType(field introspection.Field) string { + if !field.TypeRef.IsScalar() { + return "" + } + ref := field.TypeRef + if ref.Kind == introspection.TypeKindNonNull { + ref = ref.OfType + } + if ref.Kind != introspection.TypeKindScalar || ref.Name != "ID" { + return "" + } + if expectedType := field.Directives.ExpectedType(); expectedType != "" && expectedType != "Node" && !strings.HasPrefix(expectedType, "_") { + return expectedType + } + if field.Name == "id" && field.ParentObject != nil && field.ParentObject.Name != "Node" && !strings.HasPrefix(field.ParentObject.Name, "_") { + return field.ParentObject.Name + } + return "" +} + +// pascalCase change a type name into pascalCase +func (funcs typescriptTemplateFuncs) pascalCase(name string) string { + return strcase.ToCamel(name) +} + +// solve checks if a field is solvable. +func (funcs typescriptTemplateFuncs) solve(field introspection.Field) bool { + if field.TypeRef == nil { + return false + } + return field.TypeRef.IsScalar() || field.TypeRef.IsList() +} + +// subtract subtract integer a with integer b. +func (funcs typescriptTemplateFuncs) subtract(a, b int) int { + return a - b +} + +// commentToLines split a string by line breaks to be used in comments +func (funcs typescriptTemplateFuncs) commentToLines(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return []string{} + } + + // Escape */ to prevent premature closure of JSDoc block comments + s = strings.ReplaceAll(s, "*/", `*\/`) + + split := strings.Split(s, "\n") + return split +} + +// format the deprecation reason +// Example: `Replaced by @foo.` -> `// Replaced by Foo\n` +func (funcs typescriptTemplateFuncs) formatDeprecation(s string) []string { + return funcs.formatHelper("deprecated", s) +} + +func (funcs typescriptTemplateFuncs) formatExperimental(_ string) []string { + return funcs.formatHelper("experimental", "") +} + +func (funcs typescriptTemplateFuncs) formatHelper(name string, s string) []string { + r := regexp.MustCompile("`[a-zA-Z0-9_]+`") + matches := r.FindAllString(s, -1) + for _, match := range matches { + replacement := strings.TrimPrefix(match, "`") + replacement = strings.TrimSuffix(replacement, "`") + replacement = funcs.formatName(replacement) + s = strings.ReplaceAll(s, match, replacement) + } + return funcs.commentToLines("@" + name + " " + s) +} + +// isCustomScalar checks if the type is actually custom. +func (funcs typescriptTemplateFuncs) isCustomScalar(t *introspection.Type) bool { + switch introspection.Scalar(t.Name) { + case introspection.ScalarString, introspection.ScalarInt, introspection.ScalarFloat, introspection.ScalarBoolean: + return false + default: + return t.Kind == introspection.TypeKindScalar + } +} + +// isEnum checks if the type is actually custom. +func (funcs typescriptTemplateFuncs) isEnum(t *introspection.Type) bool { + return t.Kind == introspection.TypeKindEnum && + // We ignore the internal GraphQL enums + !strings.HasPrefix(t.Name, "_") +} + +func (funcs typescriptTemplateFuncs) isKeyword(s string) bool { + _, isKeyword := jsKeywords[strings.ToLower(s)] + + return isKeyword +} + +// formatName formats a GraphQL name (e.g. object, field, arg) into a TS +// equivalent, avoiding collisions with reserved words. +func (funcs typescriptTemplateFuncs) formatName(s string) string { + if _, isKeyword := jsKeywords[strings.ToLower(s)]; isKeyword { + // NB: this is case-insensitive; in JS, both function and Function cause + // problems (one straight up doesn't parse, the other causes lint errors) + return s + "_" + } + return s +} + +func (funcs typescriptTemplateFuncs) queryToClient(s string) string { + if s == generator.QueryStructName { + return generator.QueryStructClientName + } + return s +} + +// all words to avoid collisions with, whether they're reserved or not +// +// in practice, many of these work just fine as e.g. method +// names, like 'export' and 'from'. +var jsKeywords = map[string]struct{}{ + "await": {}, + "break": {}, + "case": {}, + "catch": {}, + "class": {}, + "const": {}, + "continue": {}, + "debugger": {}, + "default": {}, + "delete": {}, + "do": {}, + "else": {}, + "enum": {}, + // "export": {}, // containr.export + "extends": {}, + "false": {}, + "finally": {}, + "for": {}, + "function": {}, + "if": {}, + "implements": {}, + "import": {}, + "in": {}, + "instanceof": {}, + "interface": {}, + "new": {}, + "null": {}, + "package": {}, + "private": {}, + "protected": {}, + "public": {}, + "return": {}, + "super": {}, + "switch": {}, + "this": {}, + "throw": {}, + "true": {}, + "try": {}, + "typeof": {}, + "var": {}, + "void": {}, + "while": {}, + // "with": {}, + "yield": {}, + "as": {}, + "let": {}, + "static": {}, + "any": {}, + "boolean": {}, + "constructor": {}, + "declare": {}, + // "get": {}, + "module": {}, + "require": {}, + "number": {}, + "set": {}, + "string": {}, + "symbol": {}, + "type": {}, + // "from": {}, // container.from + // "of": {}, + "async": {}, + "namespace": {}, +} + +// formatEnum formats a GraphQL enum into a TS equivalent +func (funcs typescriptTemplateFuncs) formatEnum(s string) string { + return strcase.ToCamel(s) +} + +// isArgOptional checks if some arg are optional. +// They are, if all of there InputValues are optional. +func (funcs typescriptTemplateFuncs) isArgOptional(values introspection.InputValues) bool { + for _, v := range values { + if !v.IsOptional() { + return false + } + } + return true +} + +func (funcs typescriptTemplateFuncs) splitRequiredOptionalArgs(values introspection.InputValues) (required introspection.InputValues, optionals introspection.InputValues) { + for i, v := range values { + if !v.IsOptional() { + continue + } + + return values[:i], values[i:] + } + return values, nil +} + +func (funcs typescriptTemplateFuncs) getEnumValues(values introspection.InputValues) introspection.InputValues { + enums := introspection.InputValues{} + + for _, v := range values { + if v.TypeRef != nil && v.TypeRef.Kind == introspection.TypeKindEnum { + enums = append(enums, v) + } + + // Check parent if the parent is an enum (for instance with TypeDefKind) + if v.TypeRef.OfType != nil && v.TypeRef.OfType.Kind == introspection.TypeKindEnum { + enums = append(enums, v) + } + } + + return enums +} + +func (funcs typescriptTemplateFuncs) getInputEnumValueType(enum introspection.InputValue) string { + if enum.TypeRef.OfType != nil && enum.TypeRef.OfType.Kind == introspection.TypeKindEnum { + return enum.TypeRef.OfType.Name + } + + return enum.TypeRef.Name +} + +func (funcs typescriptTemplateFuncs) getRequiredArgs(values introspection.InputValues) introspection.InputValues { + required, _ := funcs.splitRequiredOptionalArgs(values) + return required +} + +func (funcs typescriptTemplateFuncs) getOptionalArgs(values introspection.InputValues) introspection.InputValues { + _, optional := funcs.splitRequiredOptionalArgs(values) + return optional +} + +func (funcs typescriptTemplateFuncs) sortInputFields(s []introspection.InputValue) []introspection.InputValue { + sort.SliceStable(s, func(i, j int) bool { + return s[i].Name < s[j].Name + }) + return s +} + +func (funcs typescriptTemplateFuncs) sortEnumFields(s []introspection.EnumValue) []introspection.EnumValue { + copy := slices.Clone(s) + + slices.SortStableFunc(copy, func(x, y introspection.EnumValue) int { + return cmp.Compare(strcase.ToCamel(x.Name), strcase.ToCamel(y.Name)) + }) + + copy = slices.CompactFunc(copy, func(x, y introspection.EnumValue) bool { + return strcase.ToCamel(x.Name) == strcase.ToCamel(y.Name) + }) + + return copy +} + +func (funcs typescriptTemplateFuncs) extractEnumValue(enum introspection.EnumValue) string { + return enum.Directives.EnumValue() +} + +// groupEnumByValue returns a list of lists of enums, grouped by similar enum value. +// +// Additionally, enum names within a single value are removed (which would +// result in duplicate codegen). +func (funcs typescriptTemplateFuncs) groupEnumByValue(s []introspection.EnumValue) [][]introspection.EnumValue { + m := map[string][]introspection.EnumValue{} + for _, v := range s { + value := cmp.Or(v.Directives.EnumValue(), v.Name) + if !slices.ContainsFunc(m[value], func(other introspection.EnumValue) bool { + return strcase.ToCamel(v.Name) == strcase.ToCamel(other.Name) + }) { + m[value] = append(m[value], v) + } + } + + var result [][]introspection.EnumValue + for _, v := range s { + value := cmp.Or(v.Directives.EnumValue(), v.Name) + if res, ok := m[value]; ok { + result = append(result, res) + delete(m, value) + } + } + + return result +} + +func (funcs typescriptTemplateFuncs) argsHaveDescription(values introspection.InputValues) bool { + for _, o := range values { + if strings.TrimSpace(o.Description) != "" { + return true + } + } + + return false +} + +func (funcs typescriptTemplateFuncs) toSingleType(value string) string { + return value[:len(value)-2] +} + +func (funcs typescriptTemplateFuncs) moduleRelPath(path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + + moduleParentPath := "" + if funcs.cfg.ModuleConfig != nil { + moduleParentPath = funcs.cfg.ModuleConfig.ModuleParentPath + } + + return filepath.Join( + // Path to the root of this module (since we're at the codegen root sdk/src/api/). + "../../../", + // Path to the module's context directory. + moduleParentPath, + // Path from the context directory to the target path. + path, + ) +} + +func (funcs typescriptTemplateFuncs) formatProtected(s string) string { + return strings.TrimSuffix(s, "_") +} + +func (funcs typescriptTemplateFuncs) legacyIDName(typeName string) string { + return typeName + "ID" +} + +func (funcs typescriptTemplateFuncs) legacyLoadFromIDName(typeName string) string { + return funcs.formatName("load" + typeName + "FromID") +} + +// legacyIDableTypes returns, for the file currently being rendered, the types +// whose ID alias must be declared (legacy mode only). It is scoped to the +// file's own types so each .gen.ts emits only its dependency's ID aliases: +// reading the global schema instead would re-emit every core ID in every +// dep file, duplicating client.gen.ts's aliases (TS2308 via `export *`). +func (funcs typescriptTemplateFuncs) legacyIDableTypes(fileTypes []*introspection.Type) []*introspection.Type { + if !funcs.legacyTypeScriptSDKCompat() { + return nil + } + schema := generator.GetSchema() + if schema == nil { + return nil + } + var types []*introspection.Type + for _, t := range fileTypes { + if t == nil || t.Name == "Node" || strings.HasPrefix(t.Name, "_") { + continue + } + if t.Kind != introspection.TypeKindObject && t.Kind != introspection.TypeKindInterface { + continue + } + idName := funcs.legacyIDName(t.Name) + if schema.Types.Get(idName) != nil { + continue + } + if !slices.ContainsFunc(t.Fields, func(field *introspection.Field) bool { + return field.Name == "id" && field.TypeRef != nil && field.TypeRef.IsScalar() + }) { + continue + } + types = append(types, t) + } + return types +} + +func (funcs typescriptTemplateFuncs) isClientOnly() bool { + return funcs.cfg.ClientConfig != nil +} + +func (funcs typescriptTemplateFuncs) Dependencies() []generator.ModuleSourceDependency { + return funcs.cfg.ClientConfig.ModuleDependencies +} + +func (funcs typescriptTemplateFuncs) HasLocalDependencies() bool { + for _, dep := range funcs.cfg.ClientConfig.ModuleDependencies { + if dep.Kind == "LOCAL_SOURCE" { + return true + } + } + + return false +} + +func (funcs typescriptTemplateFuncs) isBundle() bool { + return funcs.cfg.Bundle +} + +// DependencyExport describes, for a single dependency, the per-dep generated +// file and the TypeScript identifiers it contributes. client.gen.ts uses this +// to emit named imports (so inline references like `new Hello(ctx)` resolve) +// and `export *` re-exports for downstream consumers. +type DependencyExport struct { + // File is the kebab-cased basename (no extension) of the dep file. + File string + // Names are the TS identifiers (object/scalar/enum/input types plus + // per-method Opts types) the dep file exports. + Names []string + // AugmentFnName is the exported function the dep file uses to attach its + // prototype augmentations to the extendable type classes + // (e.g. `__applyHelloAugmentations`). + AugmentFnName string +} + +// AugmentationData bundles the parent class name and a dep-contributed field +// for the augmentation sub-templates (text/template only allows a single +// positional argument). +type AugmentationData struct { + Parent string + Field *introspection.Field +} + +func augmentation(parent string, field *introspection.Field) AugmentationData { + return AugmentationData{Parent: parent, Field: field} +} + +// augmentFnName derives the exported augmentation function name for a dep, +// e.g. "hello" -> "__applyHelloAugmentations". +func augmentFnName(depName string) string { + return "__apply" + strcase.ToCamel(depName) + "Augmentations" +} + +// depFileName converts a module name to the kebab-cased basename used for its +// generated file, e.g. "myDep" -> "my-dep" (file "my-dep.gen.ts"). +func (funcs typescriptTemplateFuncs) depFileName(moduleName string) string { + return strcase.ToKebab(moduleName) +} + +// isExtendableType reports whether the type is one of the core extendable +// types (Query/Binding/Env) that dependencies contribute fields to via +// prototype augmentation rather than re-declaring the class. +func (funcs typescriptTemplateFuncs) isExtendableType(t *introspection.Type) bool { + if t == nil { + return false + } + return slices.Contains(introspection.ExtendableTypes, t.Name) +} + +// dependencyFiles returns the per-dep generated filenames (kebab-cased, no +// extension), sorted by dependency name. +func (funcs typescriptTemplateFuncs) dependencyFiles() []string { + if funcs.fullSchema == nil { + return nil + } + deps := funcs.dependencyNames() + out := make([]string, len(deps)) + for i, d := range deps { + out[i] = funcs.depFileName(d) + } + return out +} + +// dependencyExports returns, for each dependency, the file basename and the +// set of TS identifiers it exports (so client.gen.ts can import + re-export +// them). The result is memoized: client.gen.ts asks for it twice (the import +// block and the footer), and each call does an Include() full-schema scan per +// dependency. +func (funcs typescriptTemplateFuncs) dependencyExports() []DependencyExport { + if funcs.fullSchema == nil { + return nil + } + if funcs.memo != nil && funcs.memo.depExportsSet { + return funcs.memo.depExports + } + deps := funcs.dependencyNames() + out := make([]DependencyExport, 0, len(deps)) + for _, dep := range deps { + depSchema := funcs.fullSchema.Include(dep) + out = append(out, DependencyExport{ + File: funcs.depFileName(dep), + Names: funcs.exportedTypeNames(depSchema.Types, nil), + AugmentFnName: augmentFnName(dep), + }) + } + if funcs.memo != nil { + funcs.memo.depExports = out + funcs.memo.depExportsSet = true + } + return out +} + +// dependencyNameSet returns the dependency module names as a set, for quick +// "is this type owned by a dependency" lookups. +func (funcs typescriptTemplateFuncs) dependencyNameSet() map[string]struct{} { + set := map[string]struct{}{} + for _, d := range funcs.dependencyNames() { + set[d] = struct{}{} + } + return set +} + +// isDependencyOwned reports whether a type is contributed by one of the +// dependencies (and so lives in a .gen.ts file rather than client.gen.ts). +func (funcs typescriptTemplateFuncs) isDependencyOwned(t *introspection.Type, depSet map[string]struct{}) bool { + if sm := t.Directives.SourceMap(); sm != nil { + _, ok := depSet[sm.Module] + return ok + } + return false +} + +// isBuiltinScalar reports whether name is a GraphQL builtin scalar that maps to +// a native TS type (string/number/float/boolean) and is therefore never +// imported or exported by name. +func isBuiltinScalar(name string) bool { + switch introspection.Scalar(name) { + case introspection.ScalarString, introspection.ScalarInt, + introspection.ScalarFloat, introspection.ScalarBoolean: + return true + } + return false +} + +// isExportableType reports whether the generated client surfaces this type by +// name — i.e. whether it can appear in a per-dep file's import and in +// client.gen.ts's re-export. It is the single predicate shared by the importing +// side (coreTypeNames/coreValueNames) and the exporting side +// (exportedTypeNames), so the two can't drift. It excludes internal +// (_-prefixed) types, builtin scalars, and the extendable types (which are +// bound from `scope` inside the augmentation function, never imported). +func (funcs typescriptTemplateFuncs) isExportableType(t *introspection.Type) bool { + if t == nil || strings.HasPrefix(t.Name, "_") { + return false + } + if slices.Contains(introspection.ExtendableTypes, t.Name) { + return false + } + return !isBuiltinScalar(t.Name) +} + +// collectReferencedNames returns every type name appearing in the dependency +// surface (field return types, argument types, and input fields). +func (funcs typescriptTemplateFuncs) collectReferencedNames(depTypes []*introspection.Type) map[string]struct{} { + referenced := map[string]struct{}{} + visit := func(ref *introspection.TypeRef) { + for ; ref != nil; ref = ref.OfType { + if ref.Name != "" { + referenced[ref.Name] = struct{}{} + } + } + } + for _, t := range depTypes { + for _, f := range t.Fields { + visit(f.TypeRef) + for _, a := range f.Args { + visit(a.TypeRef) + } + } + for _, in := range t.InputFields { + visit(in.TypeRef) + } + } + return referenced +} + +// addLegacyIDRefs adds, in legacy mode, the ID alias names referenced +// by the dependency surface. An object's `id` field (and id-typed args) render +// as the per-type alias ID rather than the generic ID scalar, so the +// dep file imports those aliases from client.gen.ts. +func (funcs typescriptTemplateFuncs) addLegacyIDRefs(depTypes []*introspection.Type, referenced map[string]struct{}) { + if !funcs.legacyTypeScriptSDKCompat() { + return + } + objectNames := map[string]struct{}{} + for _, t := range depTypes { + if t.Kind == introspection.TypeKindObject { + objectNames[t.Name] = struct{}{} + } + } + for name := range referenced { + if t := funcs.fullSchema.Types.Get(name); t != nil && t.Kind == introspection.TypeKindObject { + objectNames[name] = struct{}{} + } + } + for name := range objectNames { + idName := funcs.legacyIDName(name) + if funcs.fullSchema.Types.Get(idName) != nil { + referenced[idName] = struct{}{} + } + } +} + +// coreTypeNames returns the core (non-dependency) identifiers a per-dep file +// imports from client.gen.ts as *types only*: scalars, input objects, enum +// types, the `float` alias, and (in legacy mode) the ID aliases the dep +// references in signatures. Core *values* the dep constructs or calls (object +// classes, enum converters) are value-imported via coreValueNames instead — a +// type-only import used as a value is a hard tsc error (TS1361) and, since type +// imports are erased under ESM, a runtime ReferenceError. +func (funcs typescriptTemplateFuncs) coreTypeNames(depTypes []*introspection.Type) []string { + if funcs.fullSchema == nil { + return nil + } + + depSet := funcs.dependencyNameSet() + referenced := funcs.collectReferencedNames(depTypes) + funcs.addLegacyIDRefs(depTypes, referenced) + + seen := map[string]struct{}{} + var names []string + add := func(name string) { + if _, ok := seen[name]; ok { + return + } + seen[name] = struct{}{} + names = append(names, name) + } + for name := range referenced { + // The Float scalar is the one builtin that renders as a TS alias + // (`float`) declared in client.gen.ts rather than a native type, so the + // dep file imports that alias. + if introspection.Scalar(name) == introspection.ScalarFloat { + add("float") + continue + } + t := funcs.fullSchema.Types.Get(name) + if t == nil || !funcs.isExportableType(t) || funcs.isDependencyOwned(t, depSet) { + continue + } + // Object classes are runtime values (the bodies do `new X(ctx)`); they + // are value-imported via coreValueNames, which also covers their use as + // signature types. + if t.Kind == introspection.TypeKindObject { + continue + } + add(funcs.exportedTypeName(t)) + } + + sort.Strings(names) + return names +} + +// coreValueNames returns the core (non-dependency) identifiers the generated +// dependency bodies reference as runtime VALUES and therefore value-import from +// client.gen.ts: object classes they construct with `new`, and the enum +// converter functions (ValueToName / NameToValue) they call. +// +// Value-importing these is safe under the client.gen.ts <-> dep-file ESM cycle: +// every reference is inside a method body or the deferred augmentation +// function, never at module-evaluation time, so the binding is always +// initialized by the time it is used. +func (funcs typescriptTemplateFuncs) coreValueNames(depTypes []*introspection.Type) []string { + if funcs.fullSchema == nil { + return nil + } + + depSet := funcs.dependencyNameSet() + coreType := func(name string) *introspection.Type { + t := funcs.fullSchema.Types.Get(name) + if t == nil || !funcs.isExportableType(t) || funcs.isDependencyOwned(t, depSet) { + return nil + } + return t + } + + seen := map[string]struct{}{} + var names []string + add := func(name string) { + if _, ok := seen[name]; ok { + return + } + seen[name] = struct{}{} + names = append(names, name) + } + + // Core object classes referenced anywhere in the surface. A value import + // covers both `new X(ctx)` in bodies and X used as a signature type. + for name := range funcs.collectReferencedNames(depTypes) { + if t := coreType(name); t != nil && + t.Kind == introspection.TypeKindObject && len(t.Fields) > 0 { + add(funcs.exportedTypeName(t)) + } + } + + // Enum converters, imported only in the direction actually used (NameToValue + // for enum return values, ValueToName for enum arguments) so the import is + // never unused. The converter name matches its definition in client.gen.ts. + addEnumConverters := func(ref *introspection.TypeRef, suffix string) { + for ; ref != nil; ref = ref.OfType { + if ref.Name == "" { + continue + } + if t := coreType(ref.Name); t != nil && t.Kind == introspection.TypeKindEnum { + add(funcs.pascalCase(t.Name) + suffix) + } + } + } + for _, t := range depTypes { + for _, f := range t.Fields { + addEnumConverters(f.TypeRef, "NameToValue") + for _, a := range f.Args { + addEnumConverters(a.TypeRef, "ValueToName") + } + } + } + + sort.Strings(names) + return names +} + +// extendableClassNames returns the TS class names of the extendable core types +// (Query->Client, Binding, Env) that are actually present in the schema, in +// declaration order. Dependencies augment these classes, and client.gen.ts +// passes them to each augmentation function; gating on presence keeps the +// generated code valid against pre-Binding/Env engine schemas. +func (funcs typescriptTemplateFuncs) extendableClassNames() []string { + if funcs.fullSchema == nil { + return nil + } + var out []string + for _, name := range introspection.ExtendableTypes { + if funcs.fullSchema.Types.Get(name) != nil { + out = append(out, funcs.formatName(funcs.queryToClient(name))) + } + } + return out +} + +// exportedTypeName returns the TS identifier under which a type is exported, +// matching the per-kind naming used by the templates: objects go through +// QueryToClient+FormatName, interfaces/inputs through FormatName, while scalars +// and enums keep their raw schema name. +func (funcs typescriptTemplateFuncs) exportedTypeName(t *introspection.Type) string { + switch t.Kind { + case introspection.TypeKindObject: + return funcs.formatName(funcs.queryToClient(t.Name)) + case introspection.TypeKindInterface, introspection.TypeKindInputObject: + return funcs.formatName(t.Name) + default: + return t.Name + } +} + +// exportedTypeNames collects the sorted, de-duplicated set of TS identifiers +// (formatted type names plus per-method Opts types) for the given types, +// skipping internal (_-prefixed) types, the built-in scalars, and the +// extendable types. An optional predicate further filters which types to keep. +func (funcs typescriptTemplateFuncs) exportedTypeNames( + types []*introspection.Type, + keep func(*introspection.Type) bool, +) []string { + seen := map[string]struct{}{} + var names []string + + add := func(name string) { + if _, ok := seen[name]; ok { + return + } + seen[name] = struct{}{} + names = append(names, name) + } + + for _, t := range types { + if !funcs.isExportableType(t) { + continue + } + if keep != nil && !keep(t) { + continue + } + + add(funcs.exportedTypeName(t)) + + // Per-method Opts struct types are exported alongside the object. The + // templates name them with the raw (QueryToClient-only) type name. + if t.Kind == introspection.TypeKindObject { + for _, f := range t.Fields { + if len(funcs.getOptionalArgs(f.Args)) == 0 { + continue + } + add(funcs.queryToClient(t.Name) + funcs.pascalCase(f.Name) + "Opts") + } + } + } + + sort.Strings(names) + return names +} diff --git a/helpers/codegen/generator/typescript/templates/src/_augmentations.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/_augmentations.ts.gtpl new file mode 100644 index 0000000..d33dad6 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/_augmentations.ts.gtpl @@ -0,0 +1,126 @@ +{{- /* Augmentations template. + +For each extendable type (Client / Binding / Env) the dep contributes fields +to, emit: + + 1. A `declare module "./client.gen.js" { interface X { ... } }` block for + IDE completion + tsc visibility; + 2. A single exported function `__applyAugmentations(scope)` that attaches + the prototype methods. It is called from the bottom of client.gen.ts (after + Client/Binding/Env are defined), which avoids the ESM cycle — the dep file + can't import those classes as values without reintroducing it. + +`_ctx` is `protected` on BaseClient. Prototype methods declared outside the +class hierarchy can't access protected members at TS-compile time even though +the access works at runtime, so each body casts `this` to `any` via the +`function (this: any, ...)` signature. */ -}} + +{{ define "augmentations" -}} + +{{- /* Type-level merge: one `interface X { ... }` per extendable type that has +any dep-contributed fields, so tsc and IDEs see the added methods. */ -}} +{{- $hasAny := false }} +{{- range .Types }} + {{- if and (IsExtendableType .) .Fields }} + {{- $hasAny = true }} + {{- end }} +{{- end }} +{{- if $hasAny }} + +declare module "./client.gen.js" { +{{- range .Types }} + {{- if and (IsExtendableType .) .Fields }} + interface {{ .Name | QueryToClient | FormatName }} { + {{- range $field := .Fields }} + {{ template "augmentation_signature" $field }} + {{- end }} + } + {{- end }} +{{- end }} +} +{{ "" }} +{{- /* Runtime prototype assignments, wrapped in a deferred function so +client.gen.ts can call it after defining the extendable type classes. Only the +extendable classes the schema actually declares are imported/bound, so the +output stays valid against pre-Binding/Env engine schemas. */}} +{{- $extendables := ExtendableClassNames }} +import type { +{{- range $i, $c := $extendables }}{{ if $i }},{{ end }} + {{ $c }} as __{{ $c }} +{{- end }} +} from "./client.gen.js" + +export function {{ AugmentFnName .DepName }}(scope: { +{{- range $extendables }} + {{ . }}: typeof __{{ . }} +{{- end }} +}) { + {{- /* Bind the extendable classes as local values so prototype assignment + and `new Client/Binding/Env(...)` work — they can't be value-imported from + client.gen.ts (ESM cycle). The matching type aliases let the signatures keep + referring to them by their bare names. */}} + const { {{ range $i, $c := $extendables }}{{ if $i }}, {{ end }}{{ $c }}{{ end }} } = scope +{{- range $extendables }} + type {{ . }} = __{{ . }} +{{- end }} +{{- range .Types }} + {{- if and (IsExtendableType .) .Fields }} + {{- $parent := .Name | QueryToClient | FormatName }} + {{- range $field := .Fields }} + +{{ template "augmentation_method" (Augmentation $parent $field) }} + {{- end }} + {{- end }} +{{- end }} +} +{{- else }} + +{{- /* No extendable-type fields contributed; emit an empty function so +client.gen.ts can call it unconditionally. The scope is still accepted (and +ignored) so the unconditional footer call type-checks. */ -}} + +export function {{ AugmentFnName .DepName }}(_scope?: unknown) {} +{{- end }} +{{- end }} + +{{- /* `augmentation_signature` renders a single field's type signature inside +the `interface X { ... }` block. The dot is an introspection.Field. */ -}} +{{ define "augmentation_signature" -}} + {{- $required := GetRequiredArgs .Args -}} + {{- $optionals := GetOptionalArgs .Args -}} + {{- $parentName := .ParentObject.Name -}} + {{- if eq $parentName "Query" }}{{ $parentName = "Client" }}{{ end -}} + {{ .Name | FormatName }}( + {{- if $required }}{{ template "args" . }}{{ end -}} + {{- if $optionals -}} + {{- if $required }}, {{ end }}opts?: {{ $parentName }}{{ .Name | PascalCase }}Opts + {{- end -}} + ): {{ if Solve . }}Promise<{{ if .TypeRef.IsVoid }}void{{ else }}{{ . | FormatFieldReturnType }}{{ end }}>{{ else }}{{ .TypeRef | FormatOutputType }}{{ end }} +{{- end }} + +{{- /* `augmentation_method` renders one prototype-assignment statement. +Input is (Augmentation parent field), exposing .Parent (the TS class name) and +.Field (the introspection.Field). The assignment goes onto +`.prototype`, where is the local const bound from `scope` +above (the dep file can't value-import Client/Binding/Env from client.gen.ts — +ESM cycle). The body is shared with the class-field methods. */ -}} +{{ define "augmentation_method" -}} + {{- $field := .Field -}} + {{- $parent := .Parent -}} + {{- $required := GetRequiredArgs $field.Args -}} + {{- $optionals := GetOptionalArgs $field.Args -}} + {{- $parentName := $field.ParentObject.Name -}} + {{- if eq $parentName "Query" }}{{ $parentName = "Client" }}{{ end -}} +{{ $parent }}.prototype.{{ $field.Name | FormatName }} = {{ if Solve $field }}async {{ end }}function (this: any + {{- /* `this: any` is always the first param, so required args and opts each + always need a leading comma. */ -}} + {{- if $required -}}, {{ template "args" $field }}{{- end -}} + {{- if $optionals -}}, opts?: {{ $parentName }}{{ $field.Name | PascalCase }}Opts{{- end -}} +){{ if Solve $field }}: Promise<{{ if $field.TypeRef.IsVoid }}void{{ else }}{{ $field | FormatFieldReturnType }}{{ end }}>{{ else }}: {{ $field.TypeRef | FormatOutputType }}{{ end }} { + {{- if Solve $field }} + {{- template "method_solve_body" $field }} + {{- else }} + {{- template "method_body" $field }} + {{- end }} +} +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/src/_dep.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/_dep.ts.gtpl new file mode 100644 index 0000000..076ece0 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/_dep.ts.gtpl @@ -0,0 +1,75 @@ +{{- /* Per-dependency generated file template. + +Emits the dependency's self-contained types (scalars, enums, input objects, +regular object classes) AND the augmentations the dep contributes to the +extendable types (Client/Binding/Env). The augmentations use TypeScript +declaration merging + JS prototype assignment so the dep file extends +Client/Binding/Env without re-declaring them. + +The data shape matches the "api" template ({Schema, SchemaVersion, Types, +DepName}); Types has been pre-filtered (via Schema.Include) to the types +contributed by this dep — for extendable types, Include keeps the type but +strips its fields down to just the dep-contributed ones. */ -}} +{{ define "dep" -}} +/** + * This file was auto-generated by `client-gen`. + * Do not make direct changes to the file. + */ +{{/* BaseClient is imported from the common SDK runtime — NOT from +client.gen.ts — to avoid an ESM circular import (client.gen.ts re-exports +this dep file, so we can't import value bindings from it at load time). */}} +{{- if IsBundle }} +import { Context, BaseClient } from "./core.js" +{{- else if (not IsClientOnly) }} +import { Context, BaseClient } from "../common/context.js" +{{- else }} +import { Context, BaseClient } from "@dagger.io/dagger" +{{- end }} + +{{- /* Core classes the bodies construct (`new Container(ctx)`) and the enum +converters they call are runtime values, so they are value-imported. This is +safe under the client.gen.ts <-> dep-file ESM cycle because every reference is +inside a method body or the deferred augmentation function, never at module-eval +time. The `extends` targets stay on BaseClient (imported above), which is the +one load-time reference and lives outside client.gen.ts. */}} +{{- $coreValues := CoreValueNames .Types }} +{{- if $coreValues }} +import { +{{- range $i, $n := $coreValues }}{{ if $i }},{{ end }} + {{ $n }} +{{- end }} +} from "./client.gen.js" +{{- end }} + +{{- /* Remaining core types (scalars, input objects, enum types, the `float` +alias, legacy ID aliases) appear only in signatures, so they are +type-only imports — erased at runtime, no ESM cycle. */}} +{{- $coreTypes := CoreTypeNames .Types }} +{{- if $coreTypes }} +import type { +{{- range $i, $n := $coreTypes }}{{ if $i }},{{ end }} + {{ $n }} +{{- end }} +} from "./client.gen.js" +{{- end }} + +{{ template "types" . }} + +{{- /* Object classes for this dep. Skip extendable types — their fields are +emitted as prototype augmentations below, not as full class declarations. */ -}} +{{- range .Types }} + {{- if HasPrefix .Name "_" }} + {{- /* internal type, skip */ -}} + {{- else if IsExtendableType . }} + {{- /* handled by the augmentations block below */ -}} + {{- else if IsInterface . }} +{{""}} {{- template "interface" . }} + {{- else }} +{{""}} {{- template "object" . }} + {{- end }} +{{- end }} + +{{- /* Dep-contributed fields on extendable types: declare-module merge + +runtime prototype assignment. */ -}} +{{ template "augmentations" . }} +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/src/_method_body.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/_method_body.ts.gtpl new file mode 100644 index 0000000..eb47dad --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/_method_body.ts.gtpl @@ -0,0 +1,46 @@ +{{- /* Body of a non-solver (synchronous) method. + +Reused by: +- method.ts.gtpl (class-field arrow form: `name = (...) => { body }`) +- _augmentations.ts.gtpl (prototype-assignment form for dep-contributed + fields on extendable types: `scope.Class.prototype.name = function (this: any, ...) { body }`) + +Both contexts give `this` access to the instance (`this._ctx`), so the body +is identical. The dot is an introspection.Field. */ -}} +{{ define "method_body" -}} + {{- $required := GetRequiredArgs .Args -}} + {{- $optionals := GetOptionalArgs .Args -}} + {{- $enums := GetEnumValues .Args }} + {{- if gt (len $enums) 0 }} + const metadata = { + {{- range $v := $enums }} + {{ $v.Name | FormatName -}}: { is_enum: true, value_to_name: {{ $v | GetInputEnumValueType }}ValueToName }, + {{- end }} + } +{{ "" -}} + {{- end }} + + const ctx = this._ctx.select( + "{{ .Name }}", +{{- if or $required $optionals }} + { {{""}} + {{- with $required }} + {{- template "call_args" $required }} + {{- end }} + + {{- with $optionals }} + {{- if $required }}, {{ end -}} + ...opts + {{- end -}} + {{- if gt (len $enums) 0 -}}, __metadata: metadata{{- end -}} +{{""}} },{{- end }} + ) + + {{- if .TypeRef }} + {{- if .TypeRef.IsInterface }} + return new _{{ .TypeRef | FormatOutputType }}Client(ctx) + {{- else }} + return new {{ .TypeRef | FormatOutputType }}(ctx) + {{- end }} + {{- end }} +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/src/_method_solve_body.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/_method_solve_body.ts.gtpl new file mode 100644 index 0000000..4060d62 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/_method_solve_body.ts.gtpl @@ -0,0 +1,95 @@ +{{- /* Body of a solver (async) method that returns a Promise. + +Reused by: +- method_solve.ts.gtpl (class-field arrow form) +- _augmentations.ts.gtpl (prototype-assignment form for dep-contributed + fields on extendable types) + +The dot is an introspection.Field. */ -}} +{{ define "method_solve_body" -}} + {{- $required := GetRequiredArgs .Args -}} + {{- $optionals := GetOptionalArgs .Args -}} + {{- $convertID := ConvertID . }} + + {{- /* If it's a scalar, make possible to return its already filled value */ -}} + {{- if and (.TypeRef.IsScalar) (ne .ParentObject.Name "Query") (not $convertID) }} + if (this._{{ .Name }}) { + {{- if .TypeRef.IsVoid }} + return + {{- else }} + return this._{{ .Name }} + {{- end }} + } +{{ "" }} + {{- end }} + + {{- /* Store promise return type that might be update in case of array */ -}} + {{- $promiseRetType := . | FormatFieldReturnType -}} + + {{- if and .TypeRef.IsList (IsListOfObject .TypeRef) }} + type {{ .Name | ToLowerCase }} = { + {{- range $v := . | GetArrayField }} + {{ $v.Name | ToLowerCase }}: {{ $v | FormatFieldOutputType }} + {{- end }} + } +{{ "" }} + {{- $promiseRetType = printf "%s[]" (.Name | ToLowerCase) }} + {{- end }} + + {{- $enums := GetEnumValues .Args }} + {{- if gt (len $enums) 0 }} + const metadata = { + {{- range $v := $enums }} + {{ $v.Name | FormatName -}}: { is_enum: true, value_to_name: {{ $v | GetInputEnumValueType }}ValueToName }, + {{- end }} + } +{{ "" -}} + {{- end }} + + {{- if .TypeRef }} + const ctx = this._ctx.select( + "{{ .Name }}", + {{- /* Insert arguments. */ -}} + {{- if or $required $optionals }} + { {{""}} + {{- with $required }} + {{- template "call_args" $required }} + {{- end }} + + {{- with $optionals }} + {{- if $required }}, {{ end }} + {{- "" }}...opts + {{- end }} + {{- if gt (len $enums) 0 -}}, __metadata: metadata{{- end -}} +{{- "" }}}, + {{- end }} + ){{- /* Add subfields */ -}} + {{- if and .TypeRef.IsList (IsListOfObject .TypeRef) }}.select("{{- range $i, $v := . | GetArrayField }}{{if $i }} {{ end }}{{ $v.Name | ToLowerCase }}{{- end }}") + {{- end }} + + {{ if not .TypeRef.IsVoid }}const response: Awaited<{{ if $convertID }}{{ . | FormatFieldOutputType }}{{ else }}{{ $promiseRetType }}{{ end }}> = {{ end }}await ctx.execute() + + {{ if $convertID -}} + {{- if IsInterface .ParentObject }} + return new _{{ $promiseRetType | FormatProtected | FormatName }}Client(ctx.copy().selectNode(response, "{{ $promiseRetType | FormatProtected }}")) + {{- else }} + return new {{ $promiseRetType | FormatProtected | FormatName }}(ctx.copy().selectNode(response, "{{ $promiseRetType | FormatProtected }}")) + {{- end }} + {{- else if not .TypeRef.IsVoid -}} + {{- if and .TypeRef.IsList (IsListOfObject .TypeRef) }} + {{- if IsListOfInterface .TypeRef }} + return response.map((r) => new _{{ . | FormatReturnType | ToSingleType | FormatProtected | FormatName }}Client(ctx.copy().selectNode(r.id, "{{ . | FormatReturnType | ToSingleType | FormatProtected }}"))) + {{- else }} + return response.map((r) => new {{ . | FormatReturnType | ToSingleType | FormatProtected | FormatName }}(ctx.copy().selectNode(r.id, "{{ . | FormatReturnType | ToSingleType | FormatProtected }}"))) + {{- end }} + {{- else if and .TypeRef.IsList (IsListOfEnum .TypeRef) -}} + return response.map((r) => {{ . | FormatReturnType | ToSingleType }}NameToValue(r)) + {{- else if .TypeRef.IsEnum }} + {{- /* If it's an Enum, we receive the member name so we must convert it to the actual value */ -}} + return {{ $promiseRetType }}NameToValue(response) + {{- else }} + return response + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/src/api.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/api.ts.gtpl new file mode 100644 index 0000000..7f3ddc0 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/api.ts.gtpl @@ -0,0 +1,20 @@ +{{- /* Top level template. +Composed of: +header: static template with base client and imports. +types: types, interface and input generations. +objects: types reprensetation in classes. + +The additional {{""}} between each template simply insert +extra breaking line. + */ -}} +{{ define "api" }} + {{- template "header" }} +{{""}} + {{- template "types" . }} +{{""}} + {{- template "objects" . }} +{{""}} + {{- template "default" . }} +{{""}} + {{- template "footer" . }} +{{ end }} diff --git a/helpers/codegen/generator/typescript/templates/src/args.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/args.ts.gtpl new file mode 100644 index 0000000..70e7b03 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/args.ts.gtpl @@ -0,0 +1,22 @@ +{{- /* Write arguments. */ -}} +{{ define "args" }} + {{- $required := GetRequiredArgs .Args }} + {{- $maxIndex := Subtract (len $required) 1 }} + + {{- range $index, $value := $required }} + {{- $opt := "" }} + + {{- /* Add ? if argument is optional. */ -}} + {{- if .TypeRef.IsOptional }} + {{- $opt = "?" }} + {{- end }} + + {{- .Name | FormatName }}{{ $opt }}: {{ . | FormatInputType }} + + {{- /* we add a ", " only if it's not the last item. */ -}} + {{- if ne $index $maxIndex }} + {{- "" }}, {{ "" }} + {{- end }} + {{- end }} +{{- end }} + diff --git a/helpers/codegen/generator/typescript/templates/src/call_args.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/call_args.ts.gtpl new file mode 100644 index 0000000..49adf22 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/call_args.ts.gtpl @@ -0,0 +1,16 @@ +{{- /* Write arguments sent to method resolver. */ -}} +{{ define "call_args" }} + {{- $maxIndex := Subtract (len .) 1 }} + {{- range $index, $value := . }} + {{- if .Name | IsKeyword }} + {{ .Name }}: + {{- end }} + {{- .Name | FormatName }} + + {{- /* Add a ", " only if it's not the last item. */ -}} + {{- if ne $index $maxIndex }} + {{- "" }}, {{ "" }} + {{- end }} + {{- end }} +{{- end }} + diff --git a/helpers/codegen/generator/typescript/templates/src/default.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/default.ts.gtpl new file mode 100644 index 0000000..2cee4a2 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/default.ts.gtpl @@ -0,0 +1,5 @@ +{{ define "default" }} +export const dag = new Client() +{{ "" }} + +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/src/entrypoint/dispatch.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/entrypoint/dispatch.ts.gtpl new file mode 100644 index 0000000..1ea082c --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/entrypoint/dispatch.ts.gtpl @@ -0,0 +1,114 @@ +{{- define "entrypoint_dispatch" -}} +{{- $module := . -}} +async function invoke( + parentName: string, + fnName: string, + parentJson: any, + args: Record, +): Promise { + switch (parentName) { +{{- range $name := sortedKeysObjects $module.Objects }} +{{- $obj := index $module.Objects $name }} + case {{ jsString $obj.Name }}: { + switch (fnName) { +{{ template "entrypoint_constructor_case" (dict "Obj" $obj) }} +{{- range $mName := sortedKeysMethods $obj.Methods }} +{{- $fn := index $obj.Methods $mName }} +{{ template "entrypoint_method_case" (dict "Obj" $obj "Fn" $fn) }} +{{- end }} + default: + throw new Error(`unknown function ${fnName} on {{ $obj.Name }}`) + } + } +{{- end }} + default: + throw new Error(`unknown object ${parentName}`) + } +} + +async function dispatch() { + await connection(async () => { + const fnCall = dag.currentFunctionCall() + const parentName = await fnCall.parentName() + + if (parentName === "") { + const id = await register() + await fnCall.returnValue(JSON.stringify(id) as string & { __JSON: never }) + return + } + + const fnName = await fnCall.name() + const parentJson = JSON.parse(await fnCall.parent()) + const fnArgs = await fnCall.inputArgs() + + const args: Record = {} + for (const arg of fnArgs) { + args[await arg.name()] = JSON.parse(await arg.value()) + } + + try { + const result = await invoke(parentName, fnName, parentJson, args) + const out = result === undefined || result === null ? "null" : JSON.stringify(result) + await fnCall.returnValue(out as string & { __JSON: never }) + } catch (e: unknown) { + await fnCall.returnError(formatError(e)) + process.exit(1) + } + }, { LogOutput: process.stdout }) +} +{{- end -}} + +{{- define "entrypoint_constructor_case" -}} +{{- $obj := .Obj -}} +{{- if $obj.Constructor }} + case "": { +{{- range $arg := $obj.Constructor.Arguments }} + {{ argCoercionLine $arg }} +{{- end }} + const __result = await new {{ classRuntimeRef $obj }}( + {{- range $i, $arg := $obj.Constructor.Arguments -}} + {{- if $i }}, {{ end }}{{ coercedVarName $arg }} + {{- end -}} + ) as unknown as {{ classTypeRef $obj }} + return await serialize{{ $obj.Name }}(__result) + } +{{- else if eq $obj.Kind "class" }} + case "": { + const __result = new {{ classRuntimeRef $obj }}() + return await serialize{{ $obj.Name }}(__result) + } +{{- else }} + case "": { + return await serialize{{ $obj.Name }}({} as any) + } +{{- end -}} +{{- end -}} + +{{- define "entrypoint_method_case" -}} +{{- $obj := .Obj -}} +{{- $fn := .Fn -}} +{{- $caseName := $fn.Name -}} +{{- if $fn.Alias }}{{ $caseName = $fn.Alias }}{{ end }} + case {{ jsString $caseName }}: { + const __parent = rebuild{{ $obj.Name }}(parentJson) +{{- range $arg := $fn.Arguments }} + {{ argCoercionLine $arg }} +{{- end }} + const __result = await __parent.{{ $fn.Name }}( + {{- range $i, $arg := $fn.Arguments -}} + {{- if $i }}, {{ end -}} + {{- if $arg.IsVariadic }}...{{ end }}{{ coercedVarName $arg -}} + {{- end -}} + ) +{{- if $fn.ReturnType }} +{{- if isInteger $fn.ReturnType }} + if (typeof __result === "number" && __result % 1 !== 0) { + throw new Error(`cannot return float '${__result}' if return type is 'number' (integer), please use 'float' as return type instead`) + } +{{- end }} + return {{ serializeExpr "__result" $fn.ReturnType }} +{{- else }} + return null +{{- end }} + } +{{- end -}} diff --git a/helpers/codegen/generator/typescript/templates/src/entrypoint/entrypoint.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/entrypoint/entrypoint.ts.gtpl new file mode 100644 index 0000000..ce1eb58 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/entrypoint/entrypoint.ts.gtpl @@ -0,0 +1,32 @@ +{{- /* +Top-level template for the static dispatch __dagger.entrypoint.ts file. +Composed of: + imports — runtime SDK + user @object imports. + helpers — formatError (static). + iface_classes — per @interface wrapper class wrapping a Context. + state_helpers — per @object class rebuild()/serialize() functions. + register — register() function: typedefs registered with the engine. + dispatch — invoke() switch + dispatch() that talks to the engine. +*/ -}} +{{- define "entrypoint" -}} +// AUTO-GENERATED — DO NOT EDIT. +// Generated by Dagger TypeScript SDK introspector (cmd/codegen generate-entrypoint). +{{ template "entrypoint_imports" . }} + +{{ template "entrypoint_helpers" . }} +{{- if .Interfaces }} + +{{ template "entrypoint_iface_classes" . }} +{{- end }} + +{{ template "entrypoint_state_helpers" . }} + +{{ template "entrypoint_register" . }} + +{{ template "entrypoint_dispatch" . }} + +dispatch().catch((e) => { + console.error(e) + process.exit(2) +}) +{{- end -}} diff --git a/helpers/codegen/generator/typescript/templates/src/entrypoint/helpers.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/entrypoint/helpers.ts.gtpl new file mode 100644 index 0000000..6686e82 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/entrypoint/helpers.ts.gtpl @@ -0,0 +1,37 @@ +{{- define "entrypoint_helpers" -}} +// Load a core/dependency object from its ID via node(id:) and wrap it in the +// matching generated client class. Mirrors the SDK runtime loader; replaces the +// retired loadFromID API (removed in #12041). Some core type names +// collide with JS builtins and get a trailing "_" (e.g. "Module" -> Module_). +function __loadCoreObject(id: string, typeName: string): any { + const cls = + (__dagger as any)[typeName] ?? (__dagger as any)[typeName + "_"] + if (!cls) { + throw new Error(`generated client class not found for core type: ${typeName}`) + } + return new cls(new Context().selectNode(id, typeName)) +} + +function formatError(e: unknown): DaggerError { + if (e instanceof Error) { + let error = dag.error(e.message) + const ext = (e as { extensions?: Record }).extensions + if (ext) { + for (const [k, v] of Object.entries(ext)) { + if (v !== "" && v !== undefined && v !== null) { + error = error.withValue( + k, + JSON.stringify(v) as string & { __JSON: never }, + ) + } + } + } + return error + } + try { + return dag.error(JSON.stringify(e)) + } catch { + return dag.error(String(e)) + } +} +{{- end -}} diff --git a/helpers/codegen/generator/typescript/templates/src/entrypoint/iface_classes.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/entrypoint/iface_classes.ts.gtpl new file mode 100644 index 0000000..026c9f3 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/entrypoint/iface_classes.ts.gtpl @@ -0,0 +1,66 @@ +{{- define "entrypoint_iface_classes" -}} +{{- $module := . -}} +{{- range $name := sortedKeysIfaces $module.Interfaces -}} +{{- $iface := index $module.Interfaces $name }} +class __Iface_{{ $iface.Name }} { + constructor(public _ctx: Context) {} + + static fromID(id: string): __Iface_{{ $iface.Name }} { + return new __Iface_{{ $iface.Name }}(new Context().selectNode(id, {{ jsString (engineIfaceTypeName $iface) }})) + } + + async id(): Promise { + return await this._ctx.select("id").execute() + } +{{- range $fnName := sortedKeysMethods $iface.Functions }} +{{- $fn := index $iface.Functions $fnName }} + +{{ template "entrypoint_iface_method" (dict "Iface" $iface "Fn" $fn "Module" $module) }} +{{- end }} +} + +{{ end -}} +{{- end -}} + +{{- define "entrypoint_iface_method" -}} +{{- $iface := .Iface -}} +{{- $fn := .Fn -}} +{{- $module := .Module -}} + async {{ $fn.Name }}( + {{- range $i, $arg := $fn.Arguments -}} + {{- if $i }}, {{ end -}} + {{ $arg.Name }}{{ if $arg.IsOptional }}?{{ end }}: any + {{- end -}} + ): Promise { + const __args: Record = {} + {{- range $arg := $fn.Arguments }} + if ({{ $arg.Name }} !== undefined) __args[{{ jsString $arg.Name }}] = {{ $arg.Name }} + {{- end }} + {{- if $fn.ReturnType }} + {{- $kind := $fn.ReturnType.Kind }} + {{- if eq $kind "VOID_KIND" }} + await this._ctx.select({{ jsString $fn.Name }}, __args).execute() + {{- else if or (eq $kind "OBJECT_KIND") (eq $kind "INTERFACE_KIND") }} + return new __Iface_{{ $iface.Name }}(this._ctx.select({{ jsString $fn.Name }}, __args)) + {{- else if eq $kind "LIST_KIND" }} + {{- $inner := $fn.ReturnType.TypeDef }} + {{- if and $inner (or (eq $inner.Kind "OBJECT_KIND") (eq $inner.Kind "INTERFACE_KIND")) }} + const __ids = await this._ctx.select({{ jsString $fn.Name }}, __args).select("id").execute<{id: string}[]>() + {{- if and (eq $inner.Kind "INTERFACE_KIND") (index $module.Interfaces $inner.Name) }} + return __ids.map(({ id }) => __Iface_{{ $inner.Name }}.fromID(id)) + {{- else if and (eq $inner.Kind "OBJECT_KIND") (index $module.Objects $inner.Name) }} + return __ids.map(({ id }) => rebuild{{ $inner.Name }}({ id })) + {{- else }} + return __ids.map(({ id }) => __loadCoreObject(id, {{ jsString $inner.Name }})) + {{- end }} + {{- else }} + return await this._ctx.select({{ jsString $fn.Name }}, __args).execute() + {{- end }} + {{- else }} + return await this._ctx.select({{ jsString $fn.Name }}, __args).execute() + {{- end }} + {{- else }} + await this._ctx.select({{ jsString $fn.Name }}, __args).execute() + {{- end }} + } +{{- end -}} diff --git a/helpers/codegen/generator/typescript/templates/src/entrypoint/imports.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/entrypoint/imports.ts.gtpl new file mode 100644 index 0000000..a83354a --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/entrypoint/imports.ts.gtpl @@ -0,0 +1,17 @@ +{{- define "entrypoint_imports" -}} +{{- range plannedImports -}} +{{- if and .SideEffect (not .Names) (not .Namespace) (not .Default) }} +import "{{ .From }}" +{{- else -}} +{{- if .SideEffect }} +import "{{ .From }}" +{{- end -}} +{{- if .Namespace }} +import {{ .Namespace }} from "{{ .From }}" +{{- end -}} +{{- if or .Default .Names }} +import {{ if .Default }}{{ .Default }}{{ if .Names }}, {{ end }}{{ end }}{{ if .Names }}{ {{ range $i, $n := .Names }}{{ if $i }}, {{ end }}{{ $n }}{{ end }} }{{ end }} from "{{ .From }}" +{{- end -}} +{{- end -}} +{{- end }} +{{- end -}} diff --git a/helpers/codegen/generator/typescript/templates/src/entrypoint/register.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/entrypoint/register.ts.gtpl new file mode 100644 index 0000000..b8a8bdd --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/entrypoint/register.ts.gtpl @@ -0,0 +1,57 @@ +{{- define "entrypoint_register" -}} +{{- $module := . -}} +async function register(): Promise { + let mod = dag.module_() +{{- if $module.Description }} + mod = mod.withDescription({{ jsString $module.Description }}) +{{- end }} +{{- range $name := sortedKeysObjects $module.Objects }} +{{- $obj := index $module.Objects $name }} + let obj_{{ $obj.Name }} = dag.typeDef().withObject({{ jsString $obj.Name }} + {{- if $obj.Description }}, { description: {{ jsString $obj.Description }}{{ if $obj.Deprecated }}, deprecated: {{ jsString $obj.Deprecated }}{{ end }} } + {{- else if $obj.Deprecated }}, { deprecated: {{ jsString $obj.Deprecated }} } + {{- end }}) +{{- range $mName := sortedKeysMethods $obj.Methods }} +{{- $fn := index $obj.Methods $mName }} + obj_{{ $obj.Name }} = obj_{{ $obj.Name }}.withFunction({{ renderFunctionExpr $fn }}) +{{- end }} +{{- range $pName := sortedKeysProps $obj.Properties }} +{{- $prop := index $obj.Properties $pName }} +{{- if $prop.IsExposed }} + obj_{{ $obj.Name }} = obj_{{ $obj.Name }}.withField({{ jsString (propFieldName $prop) }}, {{ renderTypeDef $prop.Type }} + {{- if $prop.Description }}, { description: {{ jsString $prop.Description }}{{ if $prop.Deprecated }}, deprecated: {{ jsString $prop.Deprecated }}{{ end }} } + {{- else if $prop.Deprecated }}, { deprecated: {{ jsString $prop.Deprecated }} } + {{- end }}) +{{- end }} +{{- end }} +{{- if $obj.Constructor }} + obj_{{ $obj.Name }} = obj_{{ $obj.Name }}.withConstructor(dag.function_("", obj_{{ $obj.Name }}) + {{- range $arg := $obj.Constructor.Arguments }}{{ renderArgCall $arg }}{{ end }}) +{{- end }} + mod = mod.withObject(obj_{{ $obj.Name }}) +{{- end }} +{{- range $name := sortedKeysEnums $module.Enums }} +{{- $e := index $module.Enums $name }} + let enum_{{ $e.Name }} = dag.typeDef().withEnum({{ jsString $e.Name }} + {{- if $e.Description }}, { description: {{ jsString $e.Description }} }{{ end }}) +{{- range $vName := sortedKeysEnumValues $e.Values }} +{{- $v := index $e.Values $vName }} + enum_{{ $e.Name }} = enum_{{ $e.Name }}.withEnumMember({{ jsString $v.Name }}, { value: {{ jsString $v.Value }} + {{- if $v.Description }}, description: {{ jsString $v.Description }}{{ end }} + {{- if $v.Deprecated }}, deprecated: {{ jsString $v.Deprecated }}{{ end }} }) +{{- end }} + mod = mod.withEnum(enum_{{ $e.Name }}) +{{- end }} +{{- range $name := sortedKeysIfaces $module.Interfaces }} +{{- $iface := index $module.Interfaces $name }} + let iface_{{ $iface.Name }} = dag.typeDef().withInterface({{ jsString $iface.Name }} + {{- if $iface.Description }}, { description: {{ jsString $iface.Description }} }{{ end }}) +{{- range $fnName := sortedKeysMethods $iface.Functions }} +{{- $fn := index $iface.Functions $fnName }} + iface_{{ $iface.Name }} = iface_{{ $iface.Name }}.withFunction({{ renderFunctionExpr $fn }}) +{{- end }} + mod = mod.withInterface(iface_{{ $iface.Name }}) +{{- end }} + return await mod.id() +} +{{- end -}} diff --git a/helpers/codegen/generator/typescript/templates/src/entrypoint/state_helpers.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/entrypoint/state_helpers.ts.gtpl new file mode 100644 index 0000000..319bf4b --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/entrypoint/state_helpers.ts.gtpl @@ -0,0 +1,69 @@ +{{- define "entrypoint_state_helpers" -}} +{{- $module := . -}} +{{- range $name := sortedKeysObjects $module.Objects -}} +{{- $obj := index $module.Objects $name }} +{{- if and (eq $obj.Kind "class") (not $obj.IsExported) }} +const __cls_{{ $obj.Name }}: any = (() => { const c = getRegisteredClass({{ jsString $obj.Name }}); if (!c) throw new Error("class {{ $obj.Name }} is not registered (missing @object() decorator?)"); return c })() + +{{ end -}} +{{ template "entrypoint_class_rebuild" (dict "Obj" $obj "Module" $module) }} + +{{ template "entrypoint_class_serialize" (dict "Obj" $obj "Module" $module) }} + +{{ end -}} +{{- end -}} + +{{- define "entrypoint_class_rebuild" -}} +{{- $obj := .Obj -}} +function rebuild{{ $obj.Name }}(state: any): {{ classTypeRef $obj }} { +{{- if eq $obj.Kind "class" }} + const __obj = Object.assign(Object.create({{ classRuntimeRef $obj }}.prototype), state ?? {}) +{{- else }} + const __obj: any = { ...(state ?? {}) } +{{- end }} + if (state) { +{{- range $name := sortedKeysProps $obj.Properties }} +{{- $prop := index $obj.Properties $name }} +{{- if $prop.Type }} +{{- $field := propFieldName $prop }} +{{- $xform := needsTransform $prop.Type }} +{{- $renamed := ne $field $prop.Name }} +{{- if or $xform $renamed }} + if (state[{{ jsString $field }}] !== undefined && state[{{ jsString $field }}] !== null) { + __obj[{{ jsString $prop.Name }}] = {{ if $xform }}{{ coerceExpr (printf "state[%s]" (jsString $field)) $prop.Type }}{{ else }}state[{{ jsString $field }}]{{ end }} + } +{{- if $renamed }} + delete __obj[{{ jsString $field }}] +{{- end }} +{{- end }} +{{- end }} +{{- end }} + } + return __obj +} +{{- end -}} + +{{- define "entrypoint_class_serialize" -}} +{{- $obj := .Obj -}} +async function serialize{{ $obj.Name }}(__obj: {{ classTypeRef $obj }}): Promise { + if (__obj === null || __obj === undefined) return __obj + const __state: any = { ...__obj } +{{- range $name := sortedKeysProps $obj.Properties }} +{{- $prop := index $obj.Properties $name }} +{{- if $prop.Type }} +{{- $field := propFieldName $prop }} +{{- $xform := needsTransform $prop.Type }} +{{- $renamed := ne $field $prop.Name }} +{{- if or $xform $renamed }} + if ((__obj as any)[{{ jsString $prop.Name }}] !== undefined && (__obj as any)[{{ jsString $prop.Name }}] !== null) { + __state[{{ jsString $field }}] = {{ if $xform }}{{ serializeExpr (printf "(__obj as any)[%s]" (jsString $prop.Name)) $prop.Type }}{{ else }}(__obj as any)[{{ jsString $prop.Name }}]{{ end }} + } +{{- if $renamed }} + delete __state[{{ jsString $prop.Name }}] +{{- end }} +{{- end }} +{{- end }} +{{- end }} + return __state +} +{{- end -}} diff --git a/helpers/codegen/generator/typescript/templates/src/header.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/header.ts.gtpl new file mode 100644 index 0000000..7864e17 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/header.ts.gtpl @@ -0,0 +1,123 @@ +{{- /* Header template. +A static file to define BaseClient class that will be +inherited by futures objects and common types. + */ -}} +{{ define "header" -}} +/** + * This file was auto-generated by `client-gen`. + * Do not make direct changes to the file. + */ + {{- if IsBundle }} +import { Context, BaseClient } from "./core.js" +{{- else if (not IsClientOnly)}} +import { Context, BaseClient } from "../common/context.js" +{{- else }} +import { Context, BaseClient, connect as _connect, connection as _connection, ConnectOpts, CallbackFct } from "@dagger.io/dagger" +{{- end }} + +{{ if IsClientOnly }} +async function serveModuleDependencies(client: Client): Promise { + {{- /* Store the dependencies in a variable to avoid duplicating the code */ -}} + {{- $dependencies := Dependencies -}} + + {{- /* Loop over the Git dependencies and serve them */ -}} + {{- range $i, $dep := $dependencies -}} + {{- if eq $dep.Kind "GIT_SOURCE" }} + await client.moduleSource( + "{{ $dep.Source }}", + { refPin: "{{ $dep.Pin }}" }, + ) + .withName("{{ $dep.Name }}") + .asModule() + .serve() + {{ end -}} + {{- end -}} + + {{/* Serve the local module if there are any local dependencies */}} + + const modSrc = client.moduleSource(".") + const configExist = await modSrc.configExists() + + {{- if (HasLocalDependencies) }} + if (!configExist) { + console.error("WARNING: dagger.json not found but is required to load local dependencies or the module itself") + return + } + {{- end }} + + if (configExist) { + await modSrc.asModule().serve({ includeDependencies: true }) + } +} + +export async function connection( + fct: () => Promise, + cfg: ConnectOpts = {}, +) { + const wrapperFunc = async (): Promise => { + await serveModuleDependencies(dag) + + // Call the callback + await fct() + } + + return await _connection(wrapperFunc, cfg) +} + +export async function connect( + fct: CallbackFct, + cfg: ConnectOpts = {}, +) { + // Serve remote dependencies before calling the callback + const wrapperFunc = async (client: Client): Promise => { + await serveModuleDependencies(client) + + // Call the callback with the client + // This requires to use `any` to pass the type system + await fct(client as any) + } + + return await _connect(wrapperFunc as unknown as CallbackFct, cfg) +} +{{- end }} + +/** + * Declare a number as float in the Dagger API. + */ +export type float = number + +// BaseClient is re-exported so consumers that previously did +// `import { BaseClient } from "./client.gen.js"` keep working. The class +// itself lives in the common SDK runtime (see ../common/context.ts) so that +// per-dependency generated files can extend it without the ESM cycle that +// arises once client.gen.ts `export *`s those dep files. +export { BaseClient } + +{{- /* For each dependency: import its types (so inline references like +`new Hello(ctx)` resolve) plus its augmentation function, and re-export +everything so downstream consumers see the dep types via this module. */ -}} +{{- range $dep := DependencyExports }} +import { + {{ $dep.AugmentFnName }}{{ range $i, $n := $dep.Names }}, + {{ $n }}{{ end }} +} from "./{{ $dep.File }}.gen.js" +export * from "./{{ $dep.File }}.gen.js" +{{- end }} +{{- end }} + +{{- /* `footer` runs after all class definitions in client.gen.ts. It calls +each dependency's augmentation function with the now-defined Client / Binding / +Env classes so the dep-contributed prototype methods are attached at module +load time. This is why dep files only ever TYPE-import the extendable classes +from client.gen.ts — avoiding the ESM cycle. */ -}} +{{ define "footer" }} +{{- $deps := DependencyExports }} +{{- if $deps }} +{{- $extendables := ExtendableClassNames }} + +// Attach dependency-contributed prototype methods to the extendable classes. +{{- range $dep := $deps }} +{{ $dep.AugmentFnName }}({{ if $extendables }}{ {{ range $i, $c := $extendables }}{{ if $i }}, {{ end }}{{ $c }}{{ end }} }{{ end }}) +{{- end }} +{{- end }} +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/src/helper_test.go b/helpers/codegen/generator/typescript/templates/src/helper_test.go new file mode 100644 index 0000000..75e6b29 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/helper_test.go @@ -0,0 +1,32 @@ +package test + +import ( + "flag" + "os" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "codegen/generator" + "codegen/generator/typescript/templates" +) + +var updateFixtures = flag.Bool("test.update-fixtures", false, "update the test fixtures") + +func updateAndGetFixtures(t *testing.T, filepath, got string) string { + t.Helper() + if *updateFixtures { + err := os.WriteFile(filepath, []byte(got), 0o600) + require.NoError(t, err) + } + want, err := os.ReadFile(filepath) + require.NoError(t, err) + + return string(want) +} + +func templateHelper(t *testing.T) *template.Template { + t.Helper() + return templates.New("", nil, "", generator.Config{}) +} diff --git a/helpers/codegen/generator/typescript/templates/src/interface.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/interface.ts.gtpl new file mode 100644 index 0000000..f82970f --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/interface.ts.gtpl @@ -0,0 +1,92 @@ +{{- /* Generate TypeScript interface from GraphQL interface type. */ -}} +{{ define "interface" }} + {{- with . }} + {{- if .Fields }} + + {{- /* Write description. */ -}} + {{- if .Description }} + {{- /* Split comment string into a slice of one line per element. */ -}} + {{- $desc := CommentToLines .Description -}} +/** + {{- range $desc }} + * {{ . }} + {{- end }} + */ + {{- end }} +{{""}} + + {{- /* Write interface definition (TypeScript structural typing). */ -}} +export interface {{ .Name | FormatName }} { {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) {{- end }} + {{- range $field := .Fields }} + {{- if Solve . }} + {{ .Name | FormatName }}({{ template "interface_args" . }}): Promise<{{ . | FormatFieldReturnType }}> + {{- else }} + {{ .Name | FormatName }}({{ template "interface_args" . }}): {{ .TypeRef | FormatOutputType }} + {{- end }} + {{- end }} +} + +{{""}} + {{- /* Write concrete client class for query builder instantiation. */ -}} +export class _{{ .Name | FormatName }}Client extends BaseClient { {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) {{- end }} + {{- /* Write private temporary field */ -}} + {{ range $field := .Fields }} + {{- if $field.TypeRef.IsScalar }} + private readonly _{{ $field.Name }}?: {{ $field | FormatFieldOutputType }} = undefined + {{- end }} + {{- end }} + + {{- /* Create constructor for temporary field */ -}} +{{ "" }} + + /** + * Constructor is used for internal usage only, do not create object from it. + */ + constructor( + ctx?: Context, + {{- range $i, $field := .Fields }} + {{- if $field.TypeRef.IsScalar }} + _{{ $field.Name }}?: {{ $field | FormatFieldOutputType }}, + {{- end }} + {{- end }} + ) { + super(ctx) +{{ "" }} + {{- range $i, $field := .Fields }} + {{- if $field.TypeRef.IsScalar }} + this._{{ $field.Name }} = _{{ $field.Name }} + {{- end }} + {{- end }} + } + + {{- /* Write methods. */ -}} + {{- "" }}{{ range $field := .Fields }} + {{- if Solve . }} + {{- template "method_solve" $field }} + {{- else }} + {{- template "method" $field }} + {{- end }} + {{- end }} +} + {{- end }} + {{- end }} +{{ end }} + +{{- /* Write interface method arguments (just signatures, no body). */ -}} +{{ define "interface_args" }} + {{- $required := GetRequiredArgs .Args }} + {{- $optionals := GetOptionalArgs .Args }} + {{- $maxIndex := Subtract (len $required) 1 }} + + {{- range $index, $value := $required }} + {{- $opt := "" }} + {{- if .TypeRef.IsOptional }} + {{- $opt = "?" }} + {{- end }} + {{- .Name | FormatName }}{{ $opt }}: {{ . | FormatInputType }} + {{- if or (ne $index $maxIndex) $optionals }}, {{ end }} + {{- end }} + {{- if $optionals }} + {{- "" }}opts?: {{ $.ParentObject.Name }}{{ .Name | PascalCase }}Opts + {{- end }} +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/src/method.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/method.ts.gtpl new file mode 100644 index 0000000..7c036bc --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/method.ts.gtpl @@ -0,0 +1,36 @@ +{{- /* Write method. */ -}} +{{ define "method" }} + {{- $parentName := .ParentObject.Name }} + {{- $required := GetRequiredArgs .Args }} + {{- $optionals := GetOptionalArgs .Args }} + + {{- if and ($optionals) (eq $parentName "Query") }} + {{- $parentName = "Client" }} + {{- end }} + + {{- /* Write method comment. */ -}} + {{- template "method_comment" . }} + + {{- /* Write method name. */ -}} + {{- "" }} {{ .Name | FormatName }} = ( + + {{- /* Write required arguments. */ -}} + {{- if $required }} + {{- template "args" . }} + {{- end }} + + {{- /* Write optional arguments */ -}} + {{- if $optionals }} + {{- /* Insert a comma if there was previous required arguments. */ -}} + {{- if $required }}, {{ end }} + {{- "" }}opts?: {{ $parentName }}{{ .Name | PascalCase }}Opts {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) + {{ "" }} + {{- end }} + {{- end }} + + {{- /* Write return type. */ -}} + {{- "" }}){{- "" }}: {{ .TypeRef | FormatOutputType }} => { {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) {{- end }} + {{- /* Body is shared with the dep prototype augmentations. */ -}} + {{- template "method_body" . }} + } +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/src/method_comment.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/method_comment.ts.gtpl new file mode 100644 index 0000000..58add46 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/method_comment.ts.gtpl @@ -0,0 +1,72 @@ +{{- /* Write comment's method. */ -}} +{{ define "method_comment" }} + {{- $required := GetRequiredArgs .Args }} + {{- $optionals := GetOptionalArgs .Args }} + {{- $argsDesc := ArgsHaveDescription .Args }} + + {{- /* Write method description. */ -}} + {{- if or .Description $argsDesc .IsDeprecated .Directives.IsExperimental }} +{{""}} + /** + {{- /* we split the comment string into a string slice of one line per element */ -}} + {{- range CommentToLines .Description }} + * {{ . }} + {{- end }} + {{- end }} + + {{- range $required }} + {{- if .Description }} + {{- /* Reference current arg to access it in range */ -}} + {{- $arg := . }} + {{- /* Write argument description */ -}} + {{- $desc := CommentToLines .Description }} + {{- range $i, $line := $desc }} + {{- /* If it's the first line, add the JSDoc tag, otherwise treat it as a simple line */ -}} + {{- if (eq $i 0) }} + * @param {{ $arg.Name }} {{ $line }} + {{- else }} + * {{ $line }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + + {{- if ArgsHaveDescription $optionals }} + {{- range $optionals }} + {{- if .Description }} + {{- /* Reference current arg to access it in range */ -}} + {{- $arg := . }} + {{- /* Write argument description */ -}} + {{- $desc := CommentToLines .Description }} + {{- range $i, $line := $desc }} + {{- /* If it's the first line, add the JSDoc tag, otherwise treat it as a simple line */ -}} + {{- if (eq $i 0) }} + * @param opts.{{ $arg.Name }} {{ $line }} + {{- else }} + * {{ $line }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + + {{- /* Write deprecation message. */ -}} + {{- if .IsDeprecated }} + {{- $deprecationLines := FormatDeprecation .DeprecationReason }} + {{- range $deprecationLines }} + * {{ . }} + {{- end }} + {{- end }} + {{- /* Write experimental message. */ -}} + {{- if .Directives.IsExperimental }} + {{- $experimentalLines := FormatExperimental .Directives.ExperimentalReason }} + {{- range $experimentalLines }} + * {{ . }} + {{- end }} + {{- end }} + + {{- if or .Description $argsDesc .IsDeprecated .Directives.IsExperimental }} + */ + {{- end }} +{{ "" -}} +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/src/method_solve.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/method_solve.ts.gtpl new file mode 100644 index 0000000..df98bc4 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/method_solve.ts.gtpl @@ -0,0 +1,36 @@ +{{- /* Write solver method that returns a Promise. */ -}} +{{ define "method_solve" }} + {{- $parentName := .ParentObject.Name }} + {{- $required := GetRequiredArgs .Args }} + {{- $optionals := GetOptionalArgs .Args }} + {{- $convertID := ConvertID . }} + + {{- if and ($optionals) (eq $parentName "Query") }} + {{- $parentName = "Client" }} + {{- end }} + + {{- /* Write method comment. */ -}} + {{- template "method_comment" . }} + {{- /* Write async method name. */ -}} + {{- "" }} {{ .Name | FormatName }} = async ( + + {{- /* Write required arguments. */ -}} + {{- if $required }} + {{- template "args" . }} + {{- end }} + + {{- /* Write optional arguments. */ -}} + {{- if $optionals }} + {{- /* Insert a comma if there was previous required arguments. */ -}} + {{- if $required }}, {{ end }} + opts?: {{ $parentName }}{{ .Name | PascalCase }}Opts {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) + {{ "" }} + {{- end }} + {{- end }} + + {{- /* Write return type */ -}} + {{- "" }}): Promise<{{ if .TypeRef.IsVoid }}void{{ else }}{{ . | FormatFieldReturnType }}{{ end }}> => { {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) {{- end }} + {{- /* Body is shared with the dep prototype augmentations. */ -}} + {{- template "method_solve_body" . }} + } +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/src/object.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/object.ts.gtpl new file mode 100644 index 0000000..07edc7d --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/object.ts.gtpl @@ -0,0 +1,84 @@ +{{- /* Generate class from GraphQL struct query type. */ -}} +{{ define "object" }} + {{- with . }} + {{- if .Fields }} + + {{- /* Write description. */ -}} + {{- if .Description }} + {{- /* Split comment string into a slice of one line per element. */ -}} + {{- $desc := CommentToLines .Description -}} +/** + {{- range $desc }} + * {{ . }} + {{- end }} + */ + {{- end }} +{{""}} + + {{- /* Write object name. */ -}} +export class {{ .Name | QueryToClient | FormatName }} extends BaseClient { {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) {{- end }} + {{- /* Write private temporary field */ -}} + {{ range $field := .Fields }} + {{- if $field.TypeRef.IsScalar }} + private readonly _{{ $field.Name }}?: {{ $field | FormatFieldOutputType }} = undefined + {{- end }} + {{- end }} + + {{- /* Create constructor for temporary field */ -}} +{{ "" }} + + /** + * Constructor is used for internal usage only, do not create object from it. + */ + constructor( + ctx?: Context, + {{- range $i, $field := .Fields }} + {{- if $field.TypeRef.IsScalar }} + _{{ $field.Name }}?: {{ $field | FormatFieldOutputType }}, + {{- end }} + {{- end }} + ) { + super(ctx) +{{ "" }} + {{- range $i, $field := .Fields }} + {{- if $field.TypeRef.IsScalar }} + this._{{ $field.Name }} = _{{ $field.Name }} + {{- end }} + {{- end }} + } + + {{- /* Add custom method to main Client */ -}} + {{- if .Name | QueryToClient | FormatName | eq "Client" }} + + /** + * Get the Raw GraphQL client. + */ + public getGQLClient() { + return this._ctx.getGQLClient() + } + {{- end }} + + {{- /* Write methods. */ -}} + {{- "" }}{{ range $field := .Fields }} + {{- if Solve . }} + {{- template "method_solve" $field }} + {{- else }} + {{- template "method" $field }} + {{- end }} + {{- end }} + +{{- if . | IsSelfChainable }} +{{""}} + /** + * Call the provided function with current {{ .Name | QueryToClient }}. + * + * This is useful for reusability and readability by not breaking the calling chain. + */ + with = (arg: (param: {{ .Name | QueryToClient | FormatName }}) => {{ .Name | QueryToClient | FormatName }}) => { + return arg(this) + } +{{- end }} +} + {{- end }} + {{- end }} +{{ end }} diff --git a/helpers/codegen/generator/typescript/templates/src/object_test.go b/helpers/codegen/generator/typescript/templates/src/object_test.go new file mode 100644 index 0000000..455477b --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/object_test.go @@ -0,0 +1,627 @@ +package test + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "codegen/generator" + "codegen/generator/typescript/templates" + "codegen/introspection" +) + +func TestObject(t *testing.T) { + tmpl := templateHelper(t) + + object := objectInit(t, containerExecArgsJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "object", object) + + want := updateAndGetFixtures(t, "testdata/object_test_want.ts", b.String()) + require.NoError(t, err) + + require.NoError(t, err) + require.Equal(t, want, b.String()) +} + +func TestObjectCasingConsistency(t *testing.T) { + tmpl := templateHelper(t) + + object := objectInit(t, JSONValueJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "object", object) + require.NoError(t, err) + + want := updateAndGetFixtures(t, "testdata/object_json_test_want.ts", b.String()) + + require.Equal(t, want, b.String()) +} + +func TestObjectFieldDeprecated(t *testing.T) { + tmpl := templateHelper(t) + + var objectFieldDeprecatedJSON = ` + { + "kind": "OBJECT", + "name": "Test", + "description": "", + "fields": [ + { + "args": [], + "deprecationReason": "This field is deprecated and will be removed in future versions.", + "description": "", + "isDeprecated": true, + "name": "legacyField", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + } +` + + object := objectInit(t, objectFieldDeprecatedJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "object", object) + require.NoError(t, err) + + want := updateAndGetFixtures(t, "testdata/object_field_deprecated_want.ts", b.String()) + + require.Equal(t, want, b.String()) +} + +func TestInterfaceMethodOptionalArgDeprecated(t *testing.T) { + tmpl := templateHelper(t) + + var interfaceDeprecatedJSON = ` + { + "kind": "INTERFACE", + "name": "TestFooer", + "description": "", + "fields": [ + { + "args": [ + { + "defaultValue": null, + "deprecationReason": "Not needed anymore.", + "description": "", + "isDeprecated": true, + "name": "bar", + "type": { + "kind": "SCALAR", + "name": "Int" + } + } + ], + "deprecationReason": "Use Bar instead.", + "description": "", + "isDeprecated": true, + "name": "foo", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + } +` + + object := objectInit(t, interfaceDeprecatedJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "object", object) + require.NoError(t, err) + + want := updateAndGetFixtures(t, "testdata/interface_method_optional_arg_deprecated_want.ts", b.String()) + + require.Equal(t, want, b.String()) +} + +var legacyUnifiedIDSchemaJSON = ` +[ + { + "kind": "SCALAR", + "name": "ID", + "description": "", + "fields": null, + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "", + "fields": null, + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INTERFACE", + "name": "Node", + "description": "", + "fields": [ + { + "name": "id", + "description": "", + "args": [], + "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", "name": "ID" } }, + "isDeprecated": false, + "deprecationReason": null, + "directives": [ + { "name": "expectedType", "args": [{ "name": "name", "value": "\"Node\"" }] } + ] + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INTERFACE", + "name": "DepCustomIface", + "description": "", + "fields": [ + { + "name": "id", + "description": "", + "args": [], + "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", "name": "ID" } }, + "isDeprecated": false, + "deprecationReason": null, + "directives": [ + { "name": "expectedType", "args": [{ "name": "name", "value": "\"DepCustomIface\"" }] } + ] + }, + { + "name": "str", + "description": "", + "args": [], + "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", "name": "String" } }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Container", + "description": "", + "fields": [ + { + "name": "id", + "description": "", + "args": [], + "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", "name": "ID" } }, + "isDeprecated": false, + "deprecationReason": null, + "directives": [ + { "name": "expectedType", "args": [{ "name": "name", "value": "\"Container\"" }] } + ] + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "File", + "description": "", + "fields": [ + { + "name": "id", + "description": "", + "args": [], + "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", "name": "ID" } }, + "isDeprecated": false, + "deprecationReason": null, + "directives": [ + { "name": "expectedType", "args": [{ "name": "name", "value": "\"File\"" }] } + ] + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Query", + "description": "", + "fields": [ + { + "name": "container", + "description": "", + "args": [], + "type": { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", "name": "Container" } }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "file", + "description": "", + "args": [ + { + "name": "id", + "description": "", + "defaultValue": null, + "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", "name": "ID" } }, + "directives": [ + { "name": "expectedType", "args": [{ "name": "name", "value": "\"File\"" }] } + ], + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { "kind": "NON_NULL", "ofType": { "kind": "OBJECT", "name": "File" } }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + } +] +` + +func TestModernTypeScriptIDSurface(t *testing.T) { + schema := objectsInit(t, legacyUnifiedIDSchemaJSON) + generator.SetSchema(&schema) + t.Cleanup(func() { generator.SetSchema(nil) }) + + got := renderAPI(t, &schema, "v0.21.0-dev") + + require.NotContains(t, got, "export type ContainerID") + require.NotContains(t, got, "loadContainerFromID") + require.Contains(t, got, "private readonly _id?: ID = undefined") + require.Contains(t, got, "id = async (): Promise => {") + require.Contains(t, got, "const response: Awaited = await ctx.execute()") +} + +func TestModernTypeScriptFileInputNamedIDUsesFileType(t *testing.T) { + schema := objectsInit(t, legacyUnifiedIDSchemaJSON) + generator.SetSchema(&schema) + t.Cleanup(func() { generator.SetSchema(nil) }) + + got := renderAPI(t, &schema, "v0.21.0-dev") + + require.Contains(t, got, "file = (id: File): File => {") + require.NotContains(t, got, "file = (id: ID): File => {") +} + +func renderAPI(t *testing.T, schema *introspection.Schema, schemaVersion string) string { + t.Helper() + tmpl := templates.New(schemaVersion, schema, "", generator.Config{}) + data := struct { + Schema *introspection.Schema + SchemaVersion string + Types []*introspection.Type + }{ + Schema: schema, + SchemaVersion: schemaVersion, + Types: schema.Types, + } + var b bytes.Buffer + require.NoError(t, tmpl.ExecuteTemplate(&b, "api", data)) + return b.String() +} + +func TestInterfaceConvertIDMethodConstructsClient(t *testing.T) { + tmpl := templateHelper(t) + + var syncerJSON = ` + { + "kind": "INTERFACE", + "name": "Syncer", + "description": "", + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "", + "isDeprecated": false, + "name": "sync", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "ID" + } + }, + "directives": [ + { + "name": "expectedType", + "args": [ + { + "name": "name", + "value": "\"Syncer\"" + } + ] + } + ] + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + } +` + + object := objectInit(t, syncerJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "interface", object) + require.NoError(t, err) + + got := b.String() + require.Contains(t, got, "sync = async (): Promise => {") + require.Contains(t, got, `return new _SyncerClient(ctx.copy().selectNode(response, "Syncer"))`) + require.NotContains(t, got, `return new Syncer(`) +} + +func TestInterfaceListReturnUsesClientWrapper(t *testing.T) { + var schemaJSON = ` +[ + { + "kind": "SCALAR", + "name": "ID", + "description": "", + "fields": null, + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INTERFACE", + "name": "Syncer", + "description": "", + "fields": [ + { + "name": "id", + "description": "", + "args": [], + "type": { "kind": "NON_NULL", "ofType": { "kind": "SCALAR", "name": "ID" } }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Query", + "description": "", + "fields": [ + { + "name": "syncers", + "description": "", + "args": [], + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { "kind": "INTERFACE", "name": "Syncer" } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + } +] +` + schema := objectsInit(t, schemaJSON) + generator.SetSchema(&schema) + t.Cleanup(func() { generator.SetSchema(nil) }) + + got := renderAPI(t, &schema, "v0.21.0-dev") + + require.Contains(t, got, "syncers = async (): Promise => {") + require.Contains(t, got, `return response.map((r) => new _SyncerClient(ctx.copy().selectNode(r.id, "Syncer")))`) + require.NotContains(t, got, `new Syncer(`) +} + +func objectInit(t *testing.T, jsonString string) *introspection.Type { + t.Helper() + var object introspection.Type + err := json.Unmarshal([]byte(jsonString), &object) + require.NoError(t, err) + + schema := introspection.Schema{ + Types: []*introspection.Type{ + &object, + }, + } + + generator.SetSchemaParents(&schema) + return &object +} + +func objectsInit(t *testing.T, jsonString string) introspection.Schema { + t.Helper() + var objects introspection.Types + err := json.Unmarshal([]byte(jsonString), &objects) + require.NoError(t, err) + + schema := introspection.Schema{ + Types: objects, + } + + generator.SetSchemaParents(&schema) + return schema +} + +var JSONValueJSON = ` +{ + "kind": "OBJECT", + "name": "JSONValue", + "description": "", + "fields": [ + { + "name": "bytes", + "description": "", + "args": [ + { + "name": "pretty", + "description": "", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "indent", + "description": "", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null +} +` + +var containerExecArgsJSON = ` + { + "kind": "OBJECT", + "name": "Container", + "description": "", + "fields": [ + { + "name": "exec", + "description": "", + "args": [ + { + "name": "args", + "description": "", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null + }, + { + "name": "stdin", + "description": "", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "redirectStdout", + "description": "", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "redirectStderr", + "description": "", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Container", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + } +` diff --git a/helpers/codegen/generator/typescript/templates/src/objects.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/objects.ts.gtpl new file mode 100644 index 0000000..9d345b0 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/objects.ts.gtpl @@ -0,0 +1,11 @@ +{{ define "objects" }} + {{- range .Types }} + {{- if HasPrefix .Name "_" }} + {{- /* we ignore types prefixed by _ */ -}} + {{- else if IsInterface . }} +{{ "" }} {{- template "interface" . }} + {{- else }} +{{ "" }} {{- template "object" . }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/src/objects_test.go b/helpers/codegen/generator/typescript/templates/src/objects_test.go new file mode 100644 index 0000000..18a5951 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/objects_test.go @@ -0,0 +1,224 @@ +package test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestObjects(t *testing.T) { + cases := map[string]struct { + in string + wantFilePath string + }{ + "CacheVolume + Host": {objectsJSON, "testdata/objects_test_want.ts"}, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + tmpl := templateHelper(t) + + jsonData := c.in + + objects := objectsInit(t, jsonData) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "objects", objects) + + want := updateAndGetFixtures(t, c.wantFilePath, b.String()) + require.NoError(t, err) + require.Equal(t, want, b.String()) + }) + } +} + +var objectsJSON = ` +[ + { + "description": "A directory whose contents persist across runs", + "enumValues": null, + "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "", + "isDeprecated": false, + "name": "id", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "CacheVolumeID", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "CacheVolume", + "possibleTypes": null + }, + { + "description": "Information about the host execution environment", + "enumValues": null, + "fields": [ + { + "args": [ + { + "defaultValue": null, + "description": "", + "name": "path", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "defaultValue": null, + "description": "", + "name": "exclude", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + { + "defaultValue": null, + "description": "", + "name": "include", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + } + ], + "deprecationReason": null, + "description": "Access a directory on the host", + "isDeprecated": false, + "name": "directory", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Directory", + "ofType": null + } + } + }, + { + "args": [ + { + "defaultValue": null, + "description": "", + "name": "name", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "deprecationReason": null, + "description": "Lookup the value of an environment variable. Null if the variable is not available.", + "isDeprecated": false, + "name": "envVariable", + "type": { + "kind": "OBJECT", + "name": "HostVariable", + "ofType": null + } + }, + { + "args": [ + { + "defaultValue": null, + "description": "", + "name": "exclude", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + { + "defaultValue": null, + "description": "", + "name": "include", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + } + ], + "deprecationReason": null, + "description": "The current working directory on the host", + "isDeprecated": false, + "name": "workdir", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Directory", + "ofType": null + } + } + } + ], + "inputFields": null, + "interfaces": [], + "kind": "OBJECT", + "name": "Host", + "possibleTypes": null + } +] +` diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/interface_method_optional_arg_deprecated_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/interface_method_optional_arg_deprecated_want.ts new file mode 100644 index 0000000..18746eb --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/interface_method_optional_arg_deprecated_want.ts @@ -0,0 +1,36 @@ + +export class TestFooer extends BaseClient { + private readonly _foo?: string = undefined + + /** + * Constructor is used for internal usage only, do not create object from it. + */ + constructor( + ctx?: Context, + _foo?: string, + ) { + super(ctx) + + this._foo = _foo + } + + /** + * @deprecated Use Bar instead. + */ + foo = async ( + opts?: TestFooerFooOpts): Promise => { + if (this._foo) { + return this._foo + } + + const ctx = this._ctx.select( + "foo", + { ...opts}, + ) + + const response: Awaited = await ctx.execute() + + + return response + } +} diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/object_field_deprecated_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/object_field_deprecated_want.ts new file mode 100644 index 0000000..4bc3294 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/object_field_deprecated_want.ts @@ -0,0 +1,34 @@ + +export class Test extends BaseClient { + private readonly _legacyField?: string = undefined + + /** + * Constructor is used for internal usage only, do not create object from it. + */ + constructor( + ctx?: Context, + _legacyField?: string, + ) { + super(ctx) + + this._legacyField = _legacyField + } + + /** + * @deprecated This field is deprecated and will be removed in future versions. + */ + legacyField = async (): Promise => { + if (this._legacyField) { + return this._legacyField + } + + const ctx = this._ctx.select( + "legacyField", + ) + + const response: Awaited = await ctx.execute() + + + return response + } +} diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/object_json_test_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/object_json_test_want.ts new file mode 100644 index 0000000..357d043 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/object_json_test_want.ts @@ -0,0 +1,32 @@ + +export class JSONValue extends BaseClient { + private readonly _bytes?: JSON = undefined + + /** + * Constructor is used for internal usage only, do not create object from it. + */ + constructor( + ctx?: Context, + _bytes?: JSON, + ) { + super(ctx) + + this._bytes = _bytes + } + bytes = async ( + opts?: JSONValueBytesOpts): Promise => { + if (this._bytes) { + return this._bytes + } + + const ctx = this._ctx.select( + "bytes", + { ...opts}, + ) + + const response: Awaited = await ctx.execute() + + + return response + } +} diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/object_test_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/object_test_want.ts new file mode 100644 index 0000000..4d17a7c --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/object_test_want.ts @@ -0,0 +1,30 @@ + +export class Container extends BaseClient { + + /** + * Constructor is used for internal usage only, do not create object from it. + */ + constructor( + ctx?: Context, + ) { + super(ctx) + + } + exec = (opts?: ContainerExecOpts): Container => { + + const ctx = this._ctx.select( + "exec", + { ...opts }, + ) + return new Container(ctx) + } + + /** + * Call the provided function with current Container. + * + * This is useful for reusability and readability by not breaking the calling chain. + */ + with = (arg: (param: Container) => Container) => { + return arg(this) + } +} diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/objects_test_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/objects_test_want.ts new file mode 100644 index 0000000..1766fda --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/objects_test_want.ts @@ -0,0 +1,85 @@ + +/** + * A directory whose contents persist across runs + */ +export class CacheVolume extends BaseClient { + private readonly _id?: CacheVolumeID = undefined + + /** + * Constructor is used for internal usage only, do not create object from it. + */ + constructor( + ctx?: Context, + _id?: CacheVolumeID, + ) { + super(ctx) + + this._id = _id + } + id = async (): Promise => { + if (this._id) { + return this._id + } + + const ctx = this._ctx.select( + "id", + ) + + const response: Awaited = await ctx.execute() + + + return response + } +} + +/** + * Information about the host execution environment + */ +export class Host extends BaseClient { + + /** + * Constructor is used for internal usage only, do not create object from it. + */ + constructor( + ctx?: Context, + ) { + super(ctx) + + } + + /** + * Access a directory on the host + */ + directory = (path: string, opts?: HostDirectoryOpts): Directory => { + + const ctx = this._ctx.select( + "directory", + { path, ...opts }, + ) + return new Directory(ctx) + } + + /** + * Lookup the value of an environment variable. Null if the variable is not available. + */ + envVariable = (name: string): HostVariable => { + + const ctx = this._ctx.select( + "envVariable", + { name }, + ) + return new HostVariable(ctx) + } + + /** + * The current working directory on the host + */ + workdir = (opts?: HostWorkdirOpts): Directory => { + + const ctx = this._ctx.select( + "workdir", + { ...opts }, + ) + return new Directory(ctx) + } +} diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/type_test_args_deprecated_no_description_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_args_deprecated_no_description_want.ts new file mode 100644 index 0000000..37cb4e2 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_args_deprecated_no_description_want.ts @@ -0,0 +1,7 @@ + +export type ContainerApplyOpts = { + /** + * @deprecated Templates are expanded automatically. + */ + expand?: boolean +} diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/type_test_args_deprecated_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_args_deprecated_want.ts new file mode 100644 index 0000000..bc0ccb1 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_args_deprecated_want.ts @@ -0,0 +1,9 @@ + +export type ContainerApplyOpts = { + /** + * Expand template variables before applying + * + * @deprecated Templates are expanded automatically. + */ + expand?: boolean +} diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/type_test_args_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_args_want.ts new file mode 100644 index 0000000..0ddea91 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_args_want.ts @@ -0,0 +1,29 @@ + +export type ContainerExecOpts = { + /** + * Command to run instead of the container's default command + */ + args?: string[] + + /** + * Content to write to the command's standard input before closing + */ + stdin?: string + + /** + * Redirect the command's standard output to a file in the container + */ + redirectStdout?: string + + /** + * Redirect the command's standard error to a file in the container + */ + redirectStderr?: string + + /** + * Provide dagger access to the executed command + * Do not use this option unless you trust the command being executed + * The command being executed WILL BE GRANTED FULL ACCESS TO YOUR HOST FILESYSTEM + */ + experimentalPrivilegedNesting?: boolean +} diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/type_test_enum_value_deprecated_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_enum_value_deprecated_want.ts new file mode 100644 index 0000000..c8f926b --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_enum_value_deprecated_want.ts @@ -0,0 +1,34 @@ + +export enum Mode { + + /** + * @deprecated Use ModeV2 instead. + */ + Value = "VALUE", +} + +/** + * Utility function to convert a Mode value to its name so + * it can be uses as argument to call a exposed function. + */ +export function ModeValueToName(value: Mode): string { + switch (value) { + case Mode.Value: + return "VALUE" + default: + return value + } +} + +/** + * Utility function to convert a Mode name to its value so + * it can be properly used inside the module runtime. + */ +export function ModeNameToValue(name: string): Mode { + switch (name) { + case "VALUE": + return Mode.Value + default: + return name as Mode + } +} \ No newline at end of file diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/type_test_enum_with_directive_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_enum_with_directive_want.ts new file mode 100644 index 0000000..76cbd8c --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_enum_with_directive_want.ts @@ -0,0 +1,49 @@ + +/** + * Compression algorithm to use for image layers. + */ +export enum ImageLayerCompression { + EstarGz = "EStarGZ", + Estargz = ImageLayerCompression.EstarGz, + Gzip = "Gzip", + Uncompressed = "Uncompressed", + Zstd = "Zstd", +} + +/** + * Utility function to convert a ImageLayerCompression value to its name so + * it can be uses as argument to call a exposed function. + */ +export function ImageLayerCompressionValueToName(value: ImageLayerCompression): string { + switch (value) { + case ImageLayerCompression.EstarGz: + return "EStarGZ" + case ImageLayerCompression.Gzip: + return "Gzip" + case ImageLayerCompression.Uncompressed: + return "Uncompressed" + case ImageLayerCompression.Zstd: + return "Zstd" + default: + return value + } +} + +/** + * Utility function to convert a ImageLayerCompression name to its value so + * it can be properly used inside the module runtime. + */ +export function ImageLayerCompressionNameToValue(name: string): ImageLayerCompression { + switch (name) { + case "EStarGZ": + return ImageLayerCompression.EstarGz + case "Gzip": + return ImageLayerCompression.Gzip + case "Uncompressed": + return ImageLayerCompression.Uncompressed + case "Zstd": + return ImageLayerCompression.Zstd + default: + return name as ImageLayerCompression + } +} \ No newline at end of file diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/type_test_enum_without_directive_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_enum_without_directive_want.ts new file mode 100644 index 0000000..09ed5dc --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_enum_without_directive_want.ts @@ -0,0 +1,38 @@ + +/** + * Transport layer network protocol associated to a port. + */ +export enum NetworkProtocol { + Tcp = "TCP", + Udp = "UDP", +} + +/** + * Utility function to convert a NetworkProtocol value to its name so + * it can be uses as argument to call a exposed function. + */ +export function NetworkProtocolValueToName(value: NetworkProtocol): string { + switch (value) { + case NetworkProtocol.Tcp: + return "TCP" + case NetworkProtocol.Udp: + return "UDP" + default: + return value + } +} + +/** + * Utility function to convert a NetworkProtocol name to its value so + * it can be properly used inside the module runtime. + */ +export function NetworkProtocolNameToValue(name: string): NetworkProtocol { + switch (name) { + case "TCP": + return NetworkProtocol.Tcp + case "UDP": + return NetworkProtocol.Udp + default: + return name as NetworkProtocol + } +} \ No newline at end of file diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/type_test_interface_optional_arg_deprecated_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_interface_optional_arg_deprecated_want.ts new file mode 100644 index 0000000..c803c7d --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_interface_optional_arg_deprecated_want.ts @@ -0,0 +1,7 @@ + +export type TestFooerFooOpts = { + /** + * @deprecated Not needed anymore. + */ + bar?: number +} diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/type_test_scalar_comment_glob_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_scalar_comment_glob_want.ts new file mode 100644 index 0000000..66b8eb4 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_scalar_comment_glob_want.ts @@ -0,0 +1,5 @@ + +/** + * Hola **\/*.go + */ +export type Container = string & {__Container: never} diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/type_test_scalar_multiline_comment_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_scalar_multiline_comment_want.ts new file mode 100644 index 0000000..f0e6b54 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_scalar_multiline_comment_want.ts @@ -0,0 +1,6 @@ + +/** + * Container type. + * A simple container definition. + */ +export type Container = string & {__Container: never} diff --git a/helpers/codegen/generator/typescript/templates/src/testdata/type_test_scalar_want.ts b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_scalar_want.ts new file mode 100644 index 0000000..aeb69fe --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/testdata/type_test_scalar_want.ts @@ -0,0 +1,5 @@ + +/** + * Hola + */ +export type Container = string & {__Container: never} diff --git a/helpers/codegen/generator/typescript/templates/src/type_test.go b/helpers/codegen/generator/typescript/templates/src/type_test.go new file mode 100644 index 0000000..d9bd52e --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/type_test.go @@ -0,0 +1,685 @@ +package test + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestType(t *testing.T) { + t.Run("scalar", func(t *testing.T) { + wantFile := "testdata/type_test_scalar_want.ts" + + var fieldArgsTypeJSON = ` + { + "kind": "SCALAR" , + "name": "Container", + "description": "Hola" + } +` + tmpl := templateHelper(t) + + object := objectInit(t, fieldArgsTypeJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + + want := updateAndGetFixtures(t, wantFile, b.String()) + + require.NoError(t, err) + require.Equal(t, want, b.String()) + }) + + t.Run("scalar with glob comment", func(t *testing.T) { + wantFile := "testdata/type_test_scalar_comment_glob_want.ts" + + var fieldArgsTypeJSON = ` + { + "kind": "SCALAR" , + "name": "Container", + "description": "Hola **/*.go" + } +` + tmpl := templateHelper(t) + + object := objectInit(t, fieldArgsTypeJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + + want := updateAndGetFixtures(t, wantFile, b.String()) + + require.NoError(t, err) + require.Equal(t, want, b.String()) + }) + + t.Run("scalar multiline comment", func(t *testing.T) { + wantFile := "testdata/type_test_scalar_multiline_comment_want.ts" + + var fieldArgsTypeJSON = ` + { + "kind": "SCALAR", + "name": "Container", + "description": "Container type.\nA simple container definition." + } + ` + + tmpl := templateHelper(t) + + object := objectInit(t, fieldArgsTypeJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + + want := updateAndGetFixtures(t, wantFile, b.String()) + + require.NoError(t, err) + require.Equal(t, want, b.String()) + }) + + t.Run("input", func(t *testing.T) { + var expectedInputType = ` +export type BuildArg = { + /** + * Name description. + */ + name: string + value: string +} +` + + var fieldInputTypeJSON = ` + { + "kind": "INPUT_OBJECT", + "name": "BuildArg", + "description": "foo", + "inputFields": [ + { + "name": "name", + "description": "Name description.", + "defaultValue": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + { + "name": "value", + "description": "", + "defaultValue": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ] + } +` + tmpl := templateHelper(t) + + object := objectInit(t, fieldInputTypeJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + + want := expectedInputType + + require.NoError(t, err) + require.Equal(t, want, b.String()) + }) + + t.Run("input deprecated field", func(t *testing.T) { + var expectedInputType = ` +export type DeprecatedInput = { + /** + * Field description. + * + * @deprecated Use otherField instead. + */ + deprecatedField?: string +} +` + + var deprecatedInputTypeJSON = ` + { + "kind": "INPUT_OBJECT", + "name": "DeprecatedInput", + "description": "foo", + "inputFields": [ + { + "name": "deprecatedField", + "description": "Field description.", + "isDeprecated": true, + "deprecationReason": "Use otherField instead.", + "defaultValue": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ] + } +` + + tmpl := templateHelper(t) + + object := objectInit(t, deprecatedInputTypeJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + + want := expectedInputType + + require.NoError(t, err) + require.Equal(t, want, b.String()) + }) + + t.Run("args", func(t *testing.T) { + wantFile := "testdata/type_test_args_want.ts" + + var fieldArgsTypeJSON = ` + { + "description": "An OCI-compatible container, also known as a docker container", + "fields": [ + { + "args": [ + { + "defaultValue": null, + "description": "Command to run instead of the container's default command", + "name": "args", + "type": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + } + }, + { + "defaultValue": null, + "description": "Content to write to the command's standard input before closing", + "name": "stdin", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "defaultValue": null, + "description": "Redirect the command's standard output to a file in the container", + "name": "redirectStdout", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "defaultValue": null, + "description": "Redirect the command's standard error to a file in the container", + "name": "redirectStderr", + "type": { + "kind": "SCALAR", + "name": "String" + } + }, + { + "defaultValue": null, + "description": "Provide dagger access to the executed command\nDo not use this option unless you trust the command being executed\nThe command being executed WILL BE GRANTED FULL ACCESS TO YOUR HOST FILESYSTEM", + "name": "experimentalPrivilegedNesting", + "type": { + "kind": "SCALAR", + "name": "Boolean" + } + } + ], + "deprecationReason": "", + "description": "This container after executing the specified command inside it", + "isDeprecated": false, + "name": "exec", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "Container" + } + } + } + ], + "kind": "OBJECT", + "name": "Container" + } +` + tmpl := templateHelper(t) + + object := objectInit(t, fieldArgsTypeJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + + want := updateAndGetFixtures(t, wantFile, b.String()) + + require.NoError(t, err) + require.Equal(t, want, b.String()) + }) + + t.Run("args deprecated", func(t *testing.T) { + wantFile := "testdata/type_test_args_deprecated_want.ts" + + var fieldArgsDeprecatedJSON = ` + { + "description": "Container with deprecated args", + "fields": [ + { + "args": [ + { + "defaultValue": null, + "description": "Path of the configuration file", + "isDeprecated": false, + "name": "path", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + }, + { + "defaultValue": null, + "description": "Expand template variables before applying", + "isDeprecated": true, + "deprecationReason": "Templates are expanded automatically.", + "name": "expand", + "type": { + "kind": "SCALAR", + "name": "Boolean" + } + } + ], + "deprecationReason": "", + "description": "Apply configuration to the container", + "isDeprecated": false, + "name": "apply", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "Container" + } + } + } + ], + "kind": "OBJECT", + "name": "Container" + } +` + tmpl := templateHelper(t) + + object := objectInit(t, fieldArgsDeprecatedJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + + want := updateAndGetFixtures(t, wantFile, b.String()) + + require.NoError(t, err) + require.Equal(t, want, b.String()) + }) + + t.Run("args deprecated no description", func(t *testing.T) { + wantFile := "testdata/type_test_args_deprecated_no_description_want.ts" + + var fieldArgsDeprecatedNoDescJSON = ` + { + "description": "Container with deprecated args", + "fields": [ + { + "args": [ + { + "defaultValue": null, + "description": "", + "isDeprecated": true, + "deprecationReason": "Templates are expanded automatically.", + "name": "expand", + "type": { + "kind": "SCALAR", + "name": "Boolean" + } + } + ], + "deprecationReason": "", + "description": "Apply configuration to the container", + "isDeprecated": false, + "name": "apply", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "Container" + } + } + } + ], + "kind": "OBJECT", + "name": "Container" + } +` + tmpl := templateHelper(t) + + object := objectInit(t, fieldArgsDeprecatedNoDescJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + + want := updateAndGetFixtures(t, wantFile, b.String()) + + require.NoError(t, err) + require.Equal(t, want, b.String()) + }) + + t.Run("interface optional arg deprecated", func(t *testing.T) { + wantFile := "testdata/type_test_interface_optional_arg_deprecated_want.ts" + + var interfaceOptionalArgDeprecatedJSON = ` + { + "description": "Test interface with deprecated method", + "fields": [ + { + "args": [ + { + "defaultValue": null, + "description": "", + "isDeprecated": true, + "deprecationReason": "Not needed anymore.", + "name": "bar", + "type": { + "kind": "SCALAR", + "name": "Int" + } + } + ], + "deprecationReason": "Use Bar instead.", + "description": "", + "isDeprecated": true, + "name": "foo", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } + } + } + ], + "kind": "INTERFACE", + "name": "TestFooer" + } +` + + tmpl := templateHelper(t) + + object := objectInit(t, interfaceOptionalArgDeprecatedJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + + want := updateAndGetFixtures(t, wantFile, b.String()) + + require.NoError(t, err) + require.Equal(t, want, b.String()) + }) +} + +func TestTypeEnum(t *testing.T) { + t.Run("with-directive", func(t *testing.T) { + var enumJSON = ` { + "description": "Compression algorithm to use for image layers.", + "directives": [], + "enumValues": [ + { + "deprecationReason": null, + "description": "", + "directives": [ + { + "args": [ + { + "name": "value", + "value": "\"Gzip\"" + } + ], + "name": "enumValue" + } + ], + "isDeprecated": false, + "name": "Gzip" + }, + { + "deprecationReason": null, + "description": "", + "directives": [ + { + "args": [ + { + "name": "value", + "value": "\"Zstd\"" + } + ], + "name": "enumValue" + } + ], + "isDeprecated": false, + "name": "Zstd" + }, + { + "deprecationReason": null, + "description": "", + "directives": [ + { + "args": [ + { + "name": "value", + "value": "\"EStarGZ\"" + } + ], + "name": "enumValue" + } + ], + "isDeprecated": false, + "name": "EStarGZ" + }, + { + "deprecationReason": null, + "description": "", + "directives": [ + { + "args": [ + { + "name": "value", + "value": "\"Uncompressed\"" + } + ], + "name": "enumValue" + } + ], + "isDeprecated": false, + "name": "Uncompressed" + }, + { + "deprecationReason": null, + "description": "", + "directives": [ + { + "args": [ + { + "name": "value", + "value": "\"Gzip\"" + } + ], + "name": "enumValue" + } + ], + "isDeprecated": false, + "name": "GZIP" + }, + { + "deprecationReason": null, + "description": "", + "directives": [ + { + "args": [ + { + "name": "value", + "value": "\"Zstd\"" + } + ], + "name": "enumValue" + } + ], + "isDeprecated": false, + "name": "ZSTD" + }, + { + "deprecationReason": null, + "description": "", + "directives": [ + { + "args": [ + { + "name": "value", + "value": "\"EStarGZ\"" + } + ], + "name": "enumValue" + } + ], + "isDeprecated": false, + "name": "ESTARGZ" + }, + { + "deprecationReason": null, + "description": "", + "directives": [ + { + "args": [ + { + "name": "value", + "value": "\"Uncompressed\"" + } + ], + "name": "enumValue" + } + ], + "isDeprecated": false, + "name": "UNCOMPRESSED" + } + ], + "fields": [], + "inputFields": [], + "interfaces": [], + "kind": "ENUM", + "name": "ImageLayerCompression", + "possibleTypes": [] + }` + + wantFile := "testdata/type_test_enum_with_directive_want.ts" + tmpl := templateHelper(t) + + object := objectInit(t, enumJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + require.NoError(t, err) + + want := updateAndGetFixtures(t, wantFile, b.String()) + require.Equal(t, want, b.String()) + }) + + t.Run("no-directive", func(t *testing.T) { + var enumJSON = ` { + "description": "Transport layer network protocol associated to a port.", + "directives": [], + "enumValues": [ + { + "deprecationReason": null, + "description": "", + "directives": [], + "isDeprecated": false, + "name": "TCP" + }, + { + "deprecationReason": null, + "description": "", + "directives": [], + "isDeprecated": false, + "name": "UDP" + } + ], + "fields": [], + "inputFields": [], + "interfaces": [], + "kind": "ENUM", + "name": "NetworkProtocol", + "possibleTypes": [] + }` + + wantFile := "testdata/type_test_enum_without_directive_want.ts" + tmpl := templateHelper(t) + + object := objectInit(t, enumJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + require.NoError(t, err) + + want := updateAndGetFixtures(t, wantFile, b.String()) + require.Equal(t, want, b.String()) + }) + + t.Run("value deprecated", func(t *testing.T) { + wantFile := "testdata/type_test_enum_value_deprecated_want.ts" + + var enumValueDeprecatedJSON = `{ + "description": "", + "directives": [], + "enumValues": [ + { + "deprecationReason": "Use ModeV2 instead.", + "description": "", + "directives": [], + "isDeprecated": true, + "name": "VALUE" + } + ], + "kind": "ENUM", + "name": "Mode" +} +` + + tmpl := templateHelper(t) + + object := objectInit(t, enumValueDeprecatedJSON) + + var b bytes.Buffer + err := tmpl.ExecuteTemplate(&b, "type", object) + + want := updateAndGetFixtures(t, wantFile, b.String()) + + require.NoError(t, err) + require.Equal(t, want, b.String()) + }) +} diff --git a/helpers/codegen/generator/typescript/templates/src/types.ts.gtpl b/helpers/codegen/generator/typescript/templates/src/types.ts.gtpl new file mode 100644 index 0000000..33929a0 --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/src/types.ts.gtpl @@ -0,0 +1,183 @@ +{{- /* Types definition generation. +Export a type for each type or input existing in the GraphQL schema. + */ -}} +{{ define "types" }} + {{- if LegacyTypeScriptSDKCompat }} + {{- range (LegacyIDableTypes .Types) }} +export type {{ .Name | LegacyIDName }} = string & { __{{ .Name | LegacyIDName }}: never } +{{ "" }} + {{- end }} + {{- end }} + {{- range .Types }} + {{- template "type" . }} + {{- end }} +{{- end }} + +{{ define "type" }} + {{- /* Generate scalar type. */ -}} + {{- if IsCustomScalar . }} + {{- if .Description }} + {{- /* Split comment string into a slice of one line per element. */ -}} + {{- $desc := CommentToLines .Description }} +/** + {{- range $desc }} + * {{ . }} + {{- end }} + */ + {{- end }} +export type {{ .Name }} = string & {__{{ .Name }}: never} {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) {{- end }} +{{ "" }} + {{- end }} + + {{- /* Generate enum */ -}} + {{- if IsEnum . }} + {{- $needsUnscopedEnums := CheckVersionCompatibility "v0.15.0" | not }} + {{- $enumName := .Name }} + {{- if .Description }} + {{- /* Split comment string into a slice of one line per element. */ -}} + {{- $desc := CommentToLines .Description }} +/** + {{- range $desc }} + * {{ . }} + {{- end }} + */ + {{- end }} +export enum {{ $enumName }} { {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) {{- end }} + {{- range $fields := .EnumValues | SortEnumFields | GroupEnumByValue }} + {{- $mainFieldName := "" }} + {{- range $idx, $field := slice $fields }} + {{- $fieldName := ($field.Name | FormatEnum) }} + {{- $fullFieldName := printf "%s.%s" $enumName ($field.Name | FormatEnum) }} + + {{- /* Get the enum value from the directive or the field name. */ -}} + {{- $fieldValue := "" }} + {{- if eq $idx 0 }} + {{- $fieldValue = $field.Directives.EnumValue }} + {{- if not $fieldValue }} + {{- $fieldValue = $field.Name }} + {{- end }} + {{- $fieldValue = $fieldValue | printf "%q" }} + {{- $mainFieldName = $fullFieldName }} + {{- else }} + {{- $fieldValue = $mainFieldName }} + {{- end }} + + {{- if or .Description .IsDeprecated }} + {{- /* Split comment string into a slice of one line per element. */ -}} + {{- $desc := CommentToLines .Description }} + + /** + {{- range $desc }} + * {{ . }} + {{- end }} + {{- if and $desc .IsDeprecated }} + * + {{- end }} + {{- if .IsDeprecated }} + {{- $deprecationLines := FormatDeprecation .DeprecationReason }} + {{- range $deprecationLines }} + * {{ . }} + {{- end }} + {{- end }} + */ + + {{- end }} + {{ $fieldName }} = {{ $fieldValue }}, {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) {{- end }} + {{- end }} + {{- end }} +} + +/** + * Utility function to convert a {{ .Name }} value to its name so + * it can be uses as argument to call a exposed function. + */ +export function {{ .Name | PascalCase }}ValueToName(value: {{ .Name }}): string { + switch (value) { + {{- range $fields := .EnumValues | SortEnumFields | GroupEnumByValue -}} + {{- $field := index $fields 0 }} + {{- $fieldName := ($field.Name | FormatEnum) }} + case {{ $enumName }}.{{ $fieldName }}: + return "{{ $field.Name }}" + {{- end }} + default: + return value + } +} + +/** + * Utility function to convert a {{ $enumName }} name to its value so + * it can be properly used inside the module runtime. + */ +export function {{ $enumName | PascalCase }}NameToValue(name: string): {{ $enumName }} { + switch (name) { + {{- range $fields := .EnumValues | SortEnumFields | GroupEnumByValue -}} + {{- $field := index $fields 0 }} + {{- $fieldName := ($field.Name | FormatEnum) }} + case "{{ $field.Name }}": + return {{ $enumName }}.{{ $fieldName }} + {{- end }} + default: + return name as {{ $enumName }} + } +} + {{- end }} + + {{- /* Generate structure type. */ -}} + {{- with .Fields }} + {{- range . }} + {{- $optionals := GetOptionalArgs .Args }} + {{- if gt (len $optionals) 0 }} +export type {{ $.Name | QueryToClient }}{{ .Name | PascalCase }}Opts = { + {{- template "field" $optionals }} +} +{{ "" }} {{- end }} + {{- end }} + {{- end }} + + {{- /* Generate input GraphQL type. */ -}} + {{- with .InputFields }} +export type {{ $.Name | FormatName }} = { + {{- template "field" (SortInputFields .) }} +} +{{ "" }} + {{- end }} + +{{- end }} + +{{- define "field" }} + {{- range $i, $field := . }} + {{- $opt := "" }} + + {{- /* Add ? if field is optional. */ -}} + {{- if $field.TypeRef.IsOptional }} + {{- $opt = "?" }} + {{- end }} + + {{- /* Write description and deprecated annotation. */ -}} + {{- if or $field.Description $field.IsDeprecated }} + {{- if ne $i 0 }} +{{""}} + {{- end }} + /** + {{- if $field.Description }} + {{- range CommentToLines $field.Description }} + * {{ . }} + {{- end }} + {{- end }} + {{- if and $field.Description $field.IsDeprecated }} + * + {{- end }} + {{- if $field.IsDeprecated }} + {{- $deprecationLines := FormatDeprecation $field.DeprecationReason }} + {{- range $deprecationLines }} + * {{ . }} + {{- end }} + {{- end }} + */ + {{- end }} + + {{- /* Write type. */}} + {{ $field.Name }}{{ $opt }}: {{ $field | FormatInputType }} {{- with .Directives.SourceMap }} // {{ .Module }} ({{ .Filelink | ModuleRelPath }}) {{- end }} + + {{- end }} +{{- end }} diff --git a/helpers/codegen/generator/typescript/templates/templates.go b/helpers/codegen/generator/typescript/templates/templates.go new file mode 100644 index 0000000..d2c261d --- /dev/null +++ b/helpers/codegen/generator/typescript/templates/templates.go @@ -0,0 +1,77 @@ +package templates + +import ( + "embed" + "fmt" + "text/template" + + "codegen/generator" + "codegen/introspection" +) + +// all: is required so the underscore-prefixed helper templates +// (e.g. _dep.ts.gtpl) are embedded — the default //go:embed skips files +// whose names begin with "_" or ".". +// +//go:embed all:src +var srcs embed.FS + +// New creates a new template with all the template dependencies set up. +// +// fullSchema is the complete (unfiltered) schema; it is passed to the +// template funcs so the dependency-splitting helpers can enumerate deps even +// when an individual file is rendered from a filtered schema. selfModule is the +// name of the module being generated for (its own types stay in client.gen.ts; +// only dependencies are split out). +func New( + schemaVersion string, + fullSchema *introspection.Schema, + selfModule string, + cfg generator.Config, +) *template.Template { + topLevelTemplate := "api" + templateDeps := []string{ + topLevelTemplate, "header", "objects", "object", "interface", "method", "method_solve", "call_args", "method_comment", "types", "args", "default", + // Dependency-splitting templates: the per-dep file ("dep"), the + // prototype augmentations, and the shared method bodies reused by both + // the class-field methods and the augmentation prototype methods. + "_dep", "_augmentations", "_method_body", "_method_solve_body", + } + + fileNames := make([]string, 0, len(templateDeps)) + for _, tmpl := range templateDeps { + fileNames = append(fileNames, fmt.Sprintf("src/%s.ts.gtpl", tmpl)) + } + + funcs := TypescriptTemplateFuncs(schemaVersion, fullSchema, selfModule, cfg) + tmpl := template.Must(template.New(topLevelTemplate).Funcs(funcs).ParseFS(srcs, fileNames...)) + return tmpl +} + +// NewEntrypoint creates a template that renders the static dispatch +// `__dagger.entrypoint.ts` file. The templates live under +// src/entrypoint/*.gtpl and consume the typedef JSON shape produced by the +// SDK introspector. +func NewEntrypoint(module *TypedefModule, opts EntrypointOptions) *template.Template { + return template.Must( + template.New("entrypoint"). + Funcs(EntrypointTemplateFuncs(module, opts)). + ParseFS(srcs, "src/entrypoint/*.gtpl"), + ) +} + +// EntrypointOptions controls how user-source imports and SDK references are +// rendered in the generated entrypoint. +type EntrypointOptions struct { + // SDKImportPath is the bare specifier the entrypoint uses to import + // runtime helpers (defaults to "@dagger.io/dagger"). + SDKImportPath string + + // ModuleRoot is the absolute path of the user's module root, used to + // resolve relative TS import paths for each registered @object class. + ModuleRoot string + + // SourceDir is the user's source directory name relative to ModuleRoot + // (defaults to "src"). + SourceDir string +} diff --git a/helpers/codegen/go.mod b/helpers/codegen/go.mod new file mode 100644 index 0000000..534d566 --- /dev/null +++ b/helpers/codegen/go.mod @@ -0,0 +1,16 @@ +module codegen + +go 1.25.1 + +require ( + github.com/iancoleman/strcase v0.3.0 + github.com/psanford/memfs v0.0.0-20241019191636-4ef911798f9b + golang.org/x/mod v0.37.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/helpers/codegen/go.sum b/helpers/codegen/go.sum new file mode 100644 index 0000000..494bb09 --- /dev/null +++ b/helpers/codegen/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/psanford/memfs v0.0.0-20241019191636-4ef911798f9b h1:xzjEJAHum+mV5Dd5KyohRlCyP03o4yq6vNpEUtAJQzI= +github.com/psanford/memfs v0.0.0-20241019191636-4ef911798f9b/go.mod h1:tcaRap0jS3eifrEEllL6ZMd9dg8IlDpi2S1oARrQ+NI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helpers/codegen/introspection/filters.go b/helpers/codegen/introspection/filters.go new file mode 100644 index 0000000..75952ab --- /dev/null +++ b/helpers/codegen/introspection/filters.go @@ -0,0 +1,137 @@ +package introspection + +import "slices" + +// Core types that can be extended by the engine itself when +// installing a dependency. +var ExtendableTypes = []string{ + "Query", + "Binding", + "Env", +} + +// DependencyNames returns the unique list of module names that appear in +// the schema's sourceMap directives, excluding the built-in extendable types +// (Query, Binding, Env) whose fields are contributed by multiple modules. +func (s *Schema) DependencyNames() []string { + seen := map[string]struct{}{} + var names []string + + for _, t := range s.Types { + // For regular types, look at the type-level source map. + if sm := t.Directives.SourceMap(); sm != nil && sm.Module != "" { + if _, ok := seen[sm.Module]; !ok { + seen[sm.Module] = struct{}{} + names = append(names, sm.Module) + } + } + } + + slices.Sort(names) + return names +} + +// Return a copy of the current schema but with only types that comes +// from the given moduleName. +// This include the actual typedef exposed by the module, enum, interface but +// also any types that may have been extended by the engine itself for that +// dependency. +// For example: `Binding.AsXXX` etc... +func (s *Schema) Include(moduleNames ...string) *Schema { + filteredSchema := &Schema{ + QueryType: s.QueryType, + Directives: s.Directives, + } + + for _, i := range s.Types { + if slices.Contains(ExtendableTypes, i.Name) { + filteredSchema.Types = append(filteredSchema.Types, keepFieldsFromModules(i, moduleNames)) + continue + } + + if isOwnedByModules(i.Directives, moduleNames) { + filteredSchema.Types = append(filteredSchema.Types, i) + } + } + + return filteredSchema +} + +// Return a copy of the current schema but without types that comes +// from the given moduleName. +// This exclude the actual typedef exposed by the module, enum, interface but +// also any types that may have been extended by the engine itself for that +// dependency. +// For example: `Binding.AsXXX` etc... +func (s *Schema) Exclude(moduleNames ...string) *Schema { + filteredSchema := &Schema{ + QueryType: s.QueryType, + Directives: s.Directives, + } + + for _, i := range s.Types { + if slices.Contains(ExtendableTypes, i.Name) { + filteredSchema.Types = append(filteredSchema.Types, dropFieldFromModules(i, moduleNames)) + continue + } + + if !isOwnedByModules(i.Directives, moduleNames) { + filteredSchema.Types = append(filteredSchema.Types, i) + } + } + + return filteredSchema +} + +func keepFieldsFromModules(t *Type, moduleNames []string) *Type { + filteredType := &Type{ + Kind: t.Kind, + Name: t.Name, + Description: t.Description, + Fields: []*Field{}, + InputFields: t.InputFields, + Directives: t.Directives, + Interfaces: t.Interfaces, + EnumValues: t.EnumValues, + } + + for _, field := range t.Fields { + if isOwnedByModules(field.Directives, moduleNames) { + filteredType.Fields = append(filteredType.Fields, field) + } + } + + return filteredType +} + +func dropFieldFromModules(t *Type, moduleNames []string) *Type { + filteredType := &Type{ + Kind: t.Kind, + Name: t.Name, + Description: t.Description, + Fields: []*Field{}, + InputFields: t.InputFields, + Directives: t.Directives, + Interfaces: t.Interfaces, + EnumValues: t.EnumValues, + } + + for _, field := range t.Fields { + if !isOwnedByModules(field.Directives, moduleNames) { + filteredType.Fields = append(filteredType.Fields, field) + } + } + + return filteredType +} + +// Return true if the directives given by the typedef is +// originating from the list of given module. +func isOwnedByModules(directives Directives, moduleNames []string) bool { + sourceMap := directives.SourceMap() + if sourceMap == nil { + return false + } + + return slices.Contains(moduleNames, sourceMap.Module) +} diff --git a/helpers/codegen/introspection/introspection.go b/helpers/codegen/introspection/introspection.go new file mode 100644 index 0000000..79170b2 --- /dev/null +++ b/helpers/codegen/introspection/introspection.go @@ -0,0 +1,421 @@ +package introspection + +import ( + _ "embed" + "encoding/json" + "fmt" +) + +// Query is the query generated by graphiql to determine type information +// +//go:embed introspection.graphql +var Query string + +// Response is the introspection query response +type Response struct { + Schema *Schema `json:"__schema"` + SchemaVersion string `json:"__schemaVersion"` +} + +type Schema struct { + QueryType struct { + Name string `json:"name,omitempty"` + } `json:"queryType,omitempty"` + MutationType *struct { + Name string `json:"name,omitempty"` + } `json:"mutationType,omitempty"` + SubscriptionType *struct { + Name string `json:"name,omitempty"` + } `json:"subscriptionType,omitempty"` + + Types Types `json:"types"` + Directives []*DirectiveDef `json:"directives"` +} + +func (s *Schema) Query() *Type { + return s.Types.Get(s.QueryType.Name) +} + +func (s *Schema) Mutation() *Type { + if s.MutationType == nil { + return nil + } + return s.Types.Get(s.MutationType.Name) +} + +func (s *Schema) Subscription() *Type { + if s.SubscriptionType == nil { + return nil + } + return s.Types.Get(s.SubscriptionType.Name) +} + +func (s *Schema) Visit() []*Type { + v := Visitor{schema: s} + return v.Run() +} + +// Remove all occurrences of a type from the schema, including +// any fields, input fields, and enum values that reference it. +func (s *Schema) ScrubType(typeName string) { + filteredTypes := make(Types, 0, len(s.Types)) + for _, t := range s.Types { + if t.ScrubType(typeName) { + continue + } + filteredTypes = append(filteredTypes, t) + } + s.Types = filteredTypes +} + +type DirectiveDef struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Locations []string `json:"locations,omitempty"` + + // NB(vito): don't omitempty - Python complains: + // + // https://github.com/graphql-python/graphql-core/blob/758fef19194005d3a287e28a4e172e0ed7955d42/src/graphql/utilities/build_client_schema.py#L383 + Args InputValues `json:"args"` +} + +type TypeKind string + +const ( + TypeKindScalar = TypeKind("SCALAR") + TypeKindObject = TypeKind("OBJECT") + TypeKindInterface = TypeKind("INTERFACE") + TypeKindUnion = TypeKind("UNION") + TypeKindEnum = TypeKind("ENUM") + TypeKindInputObject = TypeKind("INPUT_OBJECT") + TypeKindList = TypeKind("LIST") + TypeKindNonNull = TypeKind("NON_NULL") +) + +type Scalar string + +const ( + ScalarInt = Scalar("Int") + ScalarFloat = Scalar("Float") + ScalarString = Scalar("String") + ScalarBoolean = Scalar("Boolean") + ScalarVoid = Scalar("Void") +) + +type Type struct { + Kind TypeKind `json:"kind"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Fields []*Field `json:"fields,omitempty"` + InputFields []InputValue `json:"inputFields,omitempty"` + EnumValues []EnumValue `json:"enumValues,omitempty"` + Interfaces []*Type `json:"interfaces"` + PossibleTypes []*Type `json:"possibleTypes"` + Directives Directives `json:"directives"` +} + +// Remove all occurrences of a type from the schema, including +// any fields, input fields, and enum values that reference it. +// Returns true if this type should be removed, whether because +// it is the type being scrubbed, or because it is now empty after +// scrubbing its references. +func (t *Type) ScrubType(typeName string) bool { + if t.Kind == TypeKindScalar { + return t.Name == typeName + } + + filteredFields := make([]*Field, 0, len(t.Fields)) + for _, f := range t.Fields { + if f.TypeRef.ReferencesType(typeName) { + continue + } + filteredFields = append(filteredFields, f) + } + t.Fields = filteredFields + + filteredInputFields := make([]InputValue, 0, len(t.InputFields)) + for _, f := range t.InputFields { + if f.Name == typeName { + continue + } + if f.TypeRef.ReferencesType(typeName) { + continue + } + filteredInputFields = append(filteredInputFields, f) + } + t.InputFields = filteredInputFields + + filteredEnumValues := make([]EnumValue, 0, len(t.EnumValues)) + for _, e := range t.EnumValues { + if e.Name == typeName { + continue + } + filteredEnumValues = append(filteredEnumValues, e) + } + t.EnumValues = filteredEnumValues + + // check if we removed everything from it, in which case it should + // be removed itself + isEmpty := len(t.Fields) == 0 && len(t.InputFields) == 0 && len(t.EnumValues) == 0 + return t.Name == typeName || isEmpty +} + +type Types []*Type + +func (t Types) Get(name string) *Type { + for _, i := range t { + if i.Name == name { + return i + } + } + return nil +} + +type Field struct { + Name string `json:"name"` + Description string `json:"description"` + TypeRef *TypeRef `json:"type"` + Args InputValues `json:"args"` + IsDeprecated bool `json:"isDeprecated"` + DeprecationReason *string `json:"deprecationReason"` + Directives Directives `json:"directives"` + + ParentObject *Type `json:"-"` +} + +func (f *Field) ReferencesType(typeName string) bool { + // check return + if f.TypeRef.ReferencesType(typeName) { + return true + } + // check args + for _, arg := range f.Args { + if arg.TypeRef.ReferencesType(typeName) { + return true + } + } + return false +} + +type TypeRef struct { + Kind TypeKind `json:"kind"` + Name string `json:"name,omitempty"` + OfType *TypeRef `json:"ofType,omitempty"` +} + +func (r TypeRef) IsOptional() bool { + return r.Kind != TypeKindNonNull +} + +func (r TypeRef) IsScalar() bool { + ref := r + if r.Kind == TypeKindNonNull { + ref = *ref.OfType + } + if ref.Kind == TypeKindScalar { + return true + } + if ref.Kind == TypeKindEnum { + return true + } + return false +} + +func (r TypeRef) IsObject() bool { + ref := r + if r.Kind == TypeKindNonNull { + ref = *ref.OfType + } + if ref.Kind == TypeKindObject || ref.Kind == TypeKindInterface { + return true + } + return false +} + +func (r TypeRef) IsInterface() bool { + ref := r + if r.Kind == TypeKindNonNull { + ref = *ref.OfType + } + return ref.Kind == TypeKindInterface +} + +func (r TypeRef) IsList() bool { + ref := r + if r.Kind == TypeKindNonNull { + ref = *ref.OfType + } + if ref.Kind == TypeKindList { + return true + } + return false +} + +func (r TypeRef) IsEnum() bool { + ref := r + + if r.Kind == TypeKindNonNull { + ref = *ref.OfType + } + + return ref.Kind == TypeKindEnum +} + +func (r TypeRef) IsVoid() bool { + ref := r + if r.Kind == TypeKindNonNull { + ref = *ref.OfType + } + return ref.Kind == TypeKindScalar && ref.Name == string(ScalarVoid) +} + +func (r TypeRef) ReferencesType(typeName string) bool { + if r.OfType != nil { + return r.OfType.ReferencesType(typeName) + } + return r.Name == typeName +} + +type InputValues []InputValue + +func (i InputValues) HasOptionals() bool { + for _, v := range i { + if v.IsOptional() { + return true + } + } + return false +} + +type InputValue struct { + Name string `json:"name"` + Description string `json:"description"` + DefaultValue *string `json:"defaultValue"` + TypeRef *TypeRef `json:"type"` + Directives Directives `json:"directives"` + IsDeprecated bool `json:"isDeprecated"` + DeprecationReason *string `json:"deprecationReason"` +} + +func (v InputValue) IsOptional() bool { + return v.DefaultValue != nil || (v.TypeRef != nil && v.TypeRef.IsOptional()) +} + +func (v InputValue) DefaultValueZero() bool { + if v.DefaultValue == nil { + return true + } + // detect zero-ish values in go, and avoid adding them as tags, since they + // just add clutter (and look confusing) + switch *v.DefaultValue { + case + `false`, // boolean + `0`, // int + `""`, // string + `[]`: // array + return true + } + + return false +} + +type EnumValue struct { + Name string `json:"name"` + Description string `json:"description"` + IsDeprecated bool `json:"isDeprecated"` + DeprecationReason *string `json:"deprecationReason"` + Directives Directives `json:"directives"` +} + +type Directives []*Directive + +func (t Directives) Directive(name string) *Directive { + for _, i := range t { + if i.Name == name { + return i + } + } + return nil +} + +func (t Directives) IsExperimental() bool { + return t.Directive("experimental") != nil +} + +func (t Directives) ExperimentalReason() string { + return fromJSON[string](t.Directive("experimental").Arg("reason")) +} + +type SourceMap struct { + Module string + Filename string + Line int + Column int + URL string +} + +func (sourceMap *SourceMap) Filelink() string { + if sourceMap.URL != "" { + return sourceMap.URL + } + // if no URL is provided, we can construct a reasonable fallback, assuming + // that it's a local file + return fmt.Sprintf("%s:%d:%d", sourceMap.Filename, sourceMap.Line, sourceMap.Column) +} + +func (t *Directives) SourceMap() *SourceMap { + d := t.Directive("sourceMap") + if d == nil { + return nil + } + return &SourceMap{ + Module: fromJSON[string](d.Arg("module")), + Filename: fromJSON[string](d.Arg("filename")), + Line: fromJSON[int](d.Arg("line")), + Column: fromJSON[int](d.Arg("column")), + URL: fromJSON[string](d.Arg("url")), + } +} + +func (t *Directives) ExpectedType() string { + d := t.Directive("expectedType") + if d == nil { + return "" + } + return fromJSON[string](d.Arg("name")) +} + +func (t *Directives) EnumValue() string { + d := t.Directive("enumValue") + if d == nil { + return "" + } + return fromJSON[string](d.Arg("value")) +} + +type Directive struct { + Name string `json:"name"` + Args []*DirectiveArg `json:"args"` +} + +func (t Directive) Arg(name string) *string { + for _, i := range t.Args { + if i.Name == name { + return i.Value + } + } + return nil +} + +type DirectiveArg struct { + Name string `json:"name"` + Value *string `json:"value"` +} + +func fromJSON[T any](raw *string) (t T) { + if raw == nil { + return t + } + _ = json.Unmarshal([]byte(*raw), &t) + return t +} diff --git a/helpers/codegen/introspection/introspection.graphql b/helpers/codegen/introspection/introspection.graphql new file mode 100644 index 0000000..0236bc6 --- /dev/null +++ b/helpers/codegen/introspection/introspection.graphql @@ -0,0 +1,123 @@ +query IntrospectionQuery { + __schemaVersion + __schema { + queryType { + name + } + mutationType { + name + } + subscriptionType { + name + } + types { + ...FullType + } + directives { + name + description + locations + args(includeDeprecated: true) { + ...InputValue + } + } + } +} + +fragment FullType on __Type { + kind + name + description + fields(includeDeprecated: true) { + name + description + args(includeDeprecated: true) { + ...InputValue + } + type { + ...TypeRef + } + isDeprecated + deprecationReason + directives { + ...DirectiveApplication + } + } + inputFields(includeDeprecated: true) { + ...InputValue + } + interfaces { + ...TypeRef + } + enumValues(includeDeprecated: true) { + name + description + isDeprecated + deprecationReason + directives { + ...DirectiveApplication + } + } + possibleTypes { + ...TypeRef + } + directives { + ...DirectiveApplication + } +} + +fragment InputValue on __InputValue { + name + description + type { + ...TypeRef + } + defaultValue + isDeprecated + deprecationReason + directives { + ...DirectiveApplication + } +} + +fragment TypeRef on __Type { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + ofType { + kind + name + } + } + } + } + } + } + } +} + +fragment DirectiveApplication on _DirectiveApplication { + name + args { + name + value + } +} + diff --git a/helpers/codegen/introspection/visitor.go b/helpers/codegen/introspection/visitor.go new file mode 100644 index 0000000..b0a24b3 --- /dev/null +++ b/helpers/codegen/introspection/visitor.go @@ -0,0 +1,82 @@ +package introspection + +import ( + "sort" + "strings" +) + +type Visitor struct { + schema *Schema +} + +func (v *Visitor) Run() []*Type { + sequence := []struct { + Kind TypeKind + Ignore map[string]any + }{ + { + Kind: TypeKindScalar, + Ignore: map[string]any{ + "String": struct{}{}, + "Float": struct{}{}, + "Int": struct{}{}, + "Boolean": struct{}{}, + "DateTime": struct{}{}, + }, + }, + { + Kind: TypeKindInputObject, + }, + { + Kind: TypeKindObject, + }, + { + Kind: TypeKindInterface, + }, + { + Kind: TypeKindEnum, + }, + } + + var types []*Type + for _, i := range sequence { + types = append(types, v.visit(i.Kind, i.Ignore)...) + } + return types +} + +func (v *Visitor) visit(kind TypeKind, ignore map[string]any) []*Type { + types := []*Type{} + for _, t := range v.schema.Types { + if t.Kind == kind { + // internal GraphQL type + if strings.HasPrefix(t.Name, "_") { + continue + } + if ignore != nil { + if _, ok := ignore[t.Name]; ok { + continue + } + } + types = append(types, t) + } + } + + // Sort types + sort.Slice(types, func(i, j int) bool { + return types[i].Name < types[j].Name + }) + + // Sort within the type + for _, t := range types { + sort.Slice(t.Fields, func(i, j int) bool { + return t.Fields[i].Name < t.Fields[j].Name + }) + + sort.Slice(t.InputFields, func(i, j int) bool { + return t.InputFields[i].Name < t.InputFields[j].Name + }) + } + + return types +} diff --git a/helpers/codegen/main.go b/helpers/codegen/main.go new file mode 100644 index 0000000..d5eca84 --- /dev/null +++ b/helpers/codegen/main.go @@ -0,0 +1,122 @@ +// Command codegen generates a standalone TypeScript client (client.gen.ts plus +// per-dependency .gen.ts files) from a pre-computed introspection schema. +// +// It is intentionally engine-free: the schema and the bound module's metadata +// are supplied as files, so no nested engine session is opened. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + + "codegen/generator" + typescriptgenerator "codegen/generator/typescript" + "codegen/introspection" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "codegen:", err) + os.Exit(1) + } +} + +// clientMeta is the bound module's metadata the SDK reads off +// client.moduleSource and writes to --client-meta-path. It mirrors the subset +// the client generator needs (see generator.ClientGeneratorConfig). +type clientMeta struct { + ModuleName string `json:"moduleName"` + EngineVersion string `json:"engineVersion"` + Dependencies []generator.ModuleSourceDependency `json:"dependencies"` +} + +// Dependency kinds the client template can serve. GIT_SOURCE embeds a portable +// source+refPin; LOCAL_SOURCE takes the configExists-guarded workspace-only +// serve. DIR_SOURCE (and any unknown kind) has no serve path and cannot appear +// in a persisted dependency list, so we fail closed rather than emit a client +// that silently drops the dependency and fails at runtime. +const ( + kindGitSource = "GIT_SOURCE" + kindLocalSource = "LOCAL_SOURCE" +) + +func validateDependencyKinds(deps []generator.ModuleSourceDependency) error { + for _, dep := range deps { + switch dep.Kind { + case kindGitSource, kindLocalSource: + default: + return fmt.Errorf("dependency %q has unsupported kind %q: only %s and %s can be served by a generated client", + dep.Name, dep.Kind, kindGitSource, kindLocalSource) + } + } + return nil +} + +func run() error { + var ( + introspectionPath = flag.String("introspection-json-path", "", "path to the introspection schema JSON") + clientMetaPath = flag.String("client-meta-path", "", "path to the client meta JSON (name, engineVersion, dependencies)") + outputDir = flag.String("output", ".", "output directory for the generated client") + ) + flag.Parse() + + if *introspectionPath == "" { + return fmt.Errorf("--introspection-json-path is required") + } + + introspectionJSON, err := os.ReadFile(*introspectionPath) + if err != nil { + return fmt.Errorf("read introspection json: %w", err) + } + var resp introspection.Response + if err := json.Unmarshal(introspectionJSON, &resp); err != nil { + return fmt.Errorf("unmarshal introspection json: %w", err) + } + if resp.Schema == nil { + return fmt.Errorf("introspection json has no __schema") + } + + clientConfig := &generator.ClientGeneratorConfig{} + if *clientMetaPath != "" { + metaJSON, err := os.ReadFile(*clientMetaPath) + if err != nil { + return fmt.Errorf("read client meta json: %w", err) + } + var meta clientMeta + if err := json.Unmarshal(metaJSON, &meta); err != nil { + return fmt.Errorf("unmarshal client meta json: %w", err) + } + clientConfig.ModuleName = meta.ModuleName + clientConfig.EngineVersion = meta.EngineVersion + clientConfig.ModuleDependencies = meta.Dependencies + + if err := validateDependencyKinds(meta.Dependencies); err != nil { + return err + } + } + + cfg := generator.Config{ + Lang: generator.SDKLangTypeScript, + OutputDir: *outputDir, + ClientConfig: clientConfig, + } + + generator.SetSchemaParents(resp.Schema) + + gen := &typescriptgenerator.TypeScriptGenerator{Config: cfg} + + ctx := context.Background() + state, err := gen.GenerateClient(ctx, resp.Schema, resp.SchemaVersion) + if err != nil { + return fmt.Errorf("generate client: %w", err) + } + + if err := generator.Overlay(ctx, state.Overlay, cfg.OutputDir); err != nil { + return fmt.Errorf("write generated client: %w", err) + } + + return nil +} diff --git a/helpers/codegen/main_test.go b/helpers/codegen/main_test.go new file mode 100644 index 0000000..bb3a620 --- /dev/null +++ b/helpers/codegen/main_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "testing" + + "codegen/generator" +) + +func TestValidateDependencyKinds(t *testing.T) { + tests := []struct { + name string + deps []generator.ModuleSourceDependency + wantErr bool + }{ + {name: "empty", deps: nil}, + {name: "git", deps: []generator.ModuleSourceDependency{{Kind: "GIT_SOURCE", Name: "a"}}}, + {name: "local", deps: []generator.ModuleSourceDependency{{Kind: "LOCAL_SOURCE", Name: "b"}}}, + {name: "git+local", deps: []generator.ModuleSourceDependency{{Kind: "GIT_SOURCE", Name: "a"}, {Kind: "LOCAL_SOURCE", Name: "b"}}}, + {name: "dir rejected", deps: []generator.ModuleSourceDependency{{Kind: "DIR_SOURCE", Name: "d"}}, wantErr: true}, + {name: "unknown rejected", deps: []generator.ModuleSourceDependency{{Kind: "WAT", Name: "e"}}, wantErr: true}, + {name: "one bad among good", deps: []generator.ModuleSourceDependency{{Kind: "GIT_SOURCE", Name: "a"}, {Kind: "DIR_SOURCE", Name: "d"}}, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateDependencyKinds(tt.deps) + if tt.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/helpers/config-updator/main.go b/helpers/config-updator/main.go index 097733c..40ff9ee 100644 --- a/helpers/config-updator/main.go +++ b/helpers/config-updator/main.go @@ -9,6 +9,7 @@ import ( "fmt" "io/fs" "os" + "strings" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -37,13 +38,14 @@ func main() { } func run(args []string) error { - if len(args) != 3 { - return fmt.Errorf("usage: config-updator INPUT_PATH OUTPUT_PATH") + if len(args) < 3 { + return fmt.Errorf("usage: config-updator INPUT_PATH OUTPUT_PATH [args...]") } subcommand := args[0] inputPath := args[1] outputPath := args[2] + extra := args[3:] input, err := readInput(inputPath) if err != nil { @@ -58,8 +60,22 @@ func run(args []string) error { updated, err = updateTSConfig(input) case "deno-config": updated, err = updateDenoConfig(input) + case "client-package-json": + // client-package-json INPUT OUTPUT ENGINE_VERSION MODULE_NAME + if len(extra) != 2 { + return fmt.Errorf("usage: config-updator client-package-json INPUT_PATH OUTPUT_PATH ENGINE_VERSION MODULE_NAME") + } + updated, err = updateClientPackageJSON(input, extra[0], extra[1]) + case "client-tsconfig": + updated, err = updateClientTSConfig(input) + case "client-deno-config": + // client-deno-config INPUT OUTPUT ENGINE_VERSION + if len(extra) != 1 { + return fmt.Errorf("usage: config-updator client-deno-config INPUT_PATH OUTPUT_PATH ENGINE_VERSION") + } + updated, err = updateClientDenoConfig(input, extra[0]) default: - return fmt.Errorf("unknown subcommand %q (expected one of: package-json, tsconfig, deno-config)", subcommand) + return fmt.Errorf("unknown subcommand %q (expected one of: package-json, tsconfig, deno-config, client-package-json)", subcommand) } if err != nil { return fmt.Errorf("%s: %w", subcommand, err) @@ -104,6 +120,145 @@ func updatePackageJSON(packageJSON string) (string, error) { return packageJSON, nil } +// defaultTypeScriptVersion mirrors dagger/dagger tsdistconsts.DefaultTypeScriptVersion. +const defaultTypeScriptVersion = "5.9.3" + +// updateClientPackageJSON turns the client output dir's package.json into a +// self-contained scoped package for the always-Remote client model: it declares +// @dagger.io/dagger at the module's engine version (the SDK owns this dep — unlike +// the module variant, which strips it), pins typescript, and names the package +// when unnamed. A missing/empty file starts from "{}" (see readInput), so this +// creates a fresh package.json for a brand-new client dir. +func updateClientPackageJSON(packageJSON, engineVersion, moduleName string) (string, error) { + packageJSON, err := sjson.Set(packageJSON, "type", "module") + if err != nil { + return "", fmt.Errorf("set type=module: %w", err) + } + + // Name the package only when the user hasn't. Scoped, derived from the bound + // module (see design §7): @dagger.io/-client. + if !gjson.Get(packageJSON, "name").Exists() { + packageJSON, err = sjson.Set(packageJSON, "name", scopedClientName(moduleName)) + if err != nil { + return "", fmt.Errorf("set name: %w", err) + } + } + + // The SDK owns the @dagger.io/dagger dependency for a generated client, so we + // set it (not setIfNotExists) — the pin must track the module's engine version. + packageJSON, err = sjson.Set(packageJSON, + "dependencies."+gjson.Escape(daggerLibPathAlias), + npmVersion(engineVersion), + ) + if err != nil { + return "", fmt.Errorf("set @dagger.io/dagger dependency: %w", err) + } + + packageJSON, err = setIfNotExists(packageJSON, "dependencies.typescript", defaultTypeScriptVersion) + if err != nil { + return "", fmt.Errorf("set typescript dependency: %w", err) + } + + return packageJSON, nil +} + +// npmVersion converts a dagger engine version to an npm-compatible one: strip a +// single leading "v" (v0.18.0 -> 0.18.0). A dev/pre-release suffix is kept as-is +// (design §7): the package may be unpublishable but stays installable/executable +// against that engine. +func npmVersion(engineVersion string) string { + return strings.TrimPrefix(engineVersion, "v") +} + +// scopedClientName derives @dagger.io/-client from the bound module +// name: lower-cased, every run of non-alphanumerics collapsed to a single "-", +// and leading/trailing "-" trimmed. +func scopedClientName(moduleName string) string { + var b strings.Builder + prevDash := false + for _, r := range strings.ToLower(moduleName) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + prevDash = false + } else if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + sanitized := strings.Trim(b.String(), "-") + if sanitized == "" { + // No usable module name — fall back to a bare scoped name. + return "@dagger.io/client" + } + return "@dagger.io/" + sanitized + "-client" +} + +// updateClientTSConfig fills a sane default tsconfig for a generated client, +// preserving any keys the user already set. Unlike the module variant it adds +// **no** `@dagger.io/dagger` -> `./sdk` path override: in the always-Remote model +// the bare specifier resolves from node_modules via the package.json dependency. +func updateClientTSConfig(tsConfig string) (string, error) { + defaults := []struct { + path string + value any + }{ + {"compilerOptions.target", "ES2022"}, + {"compilerOptions.moduleResolution", "Node"}, + {"compilerOptions.experimentalDecorators", true}, + {"compilerOptions.strict", true}, + {"compilerOptions.skipLibCheck", true}, + } + for _, d := range defaults { + v, err := setValueIfNotExists(tsConfig, d.path, d.value) + if err != nil { + return "", fmt.Errorf("set %s: %w", d.path, err) + } + tsConfig = v + } + return tsConfig, nil +} + +// updateClientDenoConfig configures deno.json for a generated client: the common +// Dagger deno setup plus — for the always-Remote model — the SDK-owned +// `@dagger.io/dagger` imports pinned to the engine version as `npm:` specifiers. +// (Upstream's remote path assumed the user declared these; a generated client +// dir has none, so the SDK writes them.) +func updateClientDenoConfig(denoConfig, engineVersion string) (string, error) { + denoConfig, err := setIfNotExists(denoConfig, "imports.typescript", "npm:typescript@"+defaultTypeScriptVersion) + if err != nil { + return "", fmt.Errorf("set typescript import: %w", err) + } + + denoConfig, err = sjson.Set(denoConfig, "nodeModulesDir", "auto") + if err != nil { + return "", fmt.Errorf("set nodeModulesDir: %w", err) + } + + for _, flag := range denoUnstableFlags { + denoConfig, err = appendIfNotExists(denoConfig, "unstable", flag) + if err != nil { + return "", fmt.Errorf("append unstable %s: %w", flag, err) + } + } + + denoConfig, err = sjson.Set(denoConfig, "compilerOptions.experimentalDecorators", true) + if err != nil { + return "", fmt.Errorf("set experimentalDecorators: %w", err) + } + + npmDagger := "npm:" + daggerLibPathAlias + "@" + npmVersion(engineVersion) + denoConfig, err = sjson.Set(denoConfig, "imports."+gjson.Escape(daggerLibPathAlias), npmDagger) + if err != nil { + return "", fmt.Errorf("set @dagger.io/dagger import: %w", err) + } + denoConfig, err = sjson.Set(denoConfig, "imports."+gjson.Escape(daggerTelemetryPathAlias), npmDagger+"/telemetry") + if err != nil { + return "", fmt.Errorf("set @dagger.io/dagger/telemetry import: %w", err) + } + + return denoConfig, nil +} + func updateTSConfig(tsConfig string) (string, error) { tsConfig, err := sjson.Set(tsConfig, "compilerOptions.paths."+gjson.Escape(daggerLibPathAlias), @@ -166,6 +321,20 @@ func updateDenoConfig(denoConfig string) (string, error) { return denoConfig, nil } +// setIfNotExists sets path to value only when path is absent, preserving any +// user-provided value. Mirrors tsutils.setIfNotExists. +func setIfNotExists(jsonStr, path, value string) (string, error) { + return setValueIfNotExists(jsonStr, path, value) +} + +// setValueIfNotExists is setIfNotExists for non-string JSON values (bool, etc.). +func setValueIfNotExists(jsonStr, path string, value any) (string, error) { + if gjson.Get(jsonStr, path).Exists() { + return jsonStr, nil + } + return sjson.Set(jsonStr, path, value) +} + func appendIfNotExists(jsonStr, path, value string) (string, error) { for _, v := range gjson.Get(jsonStr, path).Array() { if v.String() == value { diff --git a/helpers/config-updator/main_test.go b/helpers/config-updator/main_test.go index 65471eb..33acb1b 100644 --- a/helpers/config-updator/main_test.go +++ b/helpers/config-updator/main_test.go @@ -402,3 +402,61 @@ func TestReadInput(t *testing.T) { require.JSONEq(t, `{"name": "demo"}`, got) }) } + +func TestUpdateClientPackageJSON(t *testing.T) { + type testCase struct { + name string + packageJSON string + engineVersion string + moduleName string + expected string + } + + for _, tc := range []testCase{ + { + name: "fresh client dir creates scoped package", + packageJSON: `{}`, + engineVersion: "v0.18.0", + moduleName: "My Cool Module", + expected: `{"type":"module","name":"@dagger.io/my-cool-module-client","dependencies":{"@dagger.io/dagger":"0.18.0","typescript":"5.9.3"}}`, + }, + { + name: "existing name is preserved, sdk dep is set, typescript kept", + packageJSON: `{"name":"@acme/existing","dependencies":{"typescript":"5.0.0"}}`, + engineVersion: "v0.19.0-dev.abc123", + moduleName: "my-cool-module", + expected: `{"name":"@acme/existing","type":"module","dependencies":{"@dagger.io/dagger":"0.19.0-dev.abc123","typescript":"5.0.0"}}`, + }, + { + name: "empty module name falls back to client", + packageJSON: `{}`, + engineVersion: "0.20.0", + moduleName: "", + expected: `{"type":"module","name":"@dagger.io/client","dependencies":{"@dagger.io/dagger":"0.20.0","typescript":"5.9.3"}}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + res, err := updateClientPackageJSON(removeJSONComments(tc.packageJSON), tc.engineVersion, tc.moduleName) + require.NoError(t, err) + require.JSONEq(t, tc.expected, res) + }) + } +} + +func TestScopedClientName(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"hello", "@dagger.io/hello-client"}, + {"My Cool Module", "@dagger.io/my-cool-module-client"}, + {"foo_bar.baz", "@dagger.io/foo-bar-baz-client"}, + {"--weird--", "@dagger.io/weird-client"}, + {"", "@dagger.io/client"}, + } { + require.Equal(t, tc.want, scopedClientName(tc.in)) + } +} + +func TestNpmVersion(t *testing.T) { + require.Equal(t, "0.18.0", npmVersion("v0.18.0")) + require.Equal(t, "0.18.0", npmVersion("0.18.0")) + require.Equal(t, "0.19.0-dev.abc", npmVersion("v0.19.0-dev.abc")) +} diff --git a/typescript-sdk.dang b/typescript-sdk.dang index 86d91c6..c18221f 100644 --- a/typescript-sdk.dang +++ b/typescript-sdk.dang @@ -284,6 +284,125 @@ type TypescriptSdk { .withExec(["go", "build", "-o", "/usr/local/bin/module-config", "."]) } + """ + Container with the standalone TypeScript client generator compiled and on + PATH at /usr/local/bin/codegen. Engine-free: it turns an introspection schema + + client meta JSON into client.gen.ts (and per-dependency .gen.ts files). + """ + let codegenBuilder: Container! { + container + .from("golang:1.25-alpine") + .withoutEntrypoint + .withMountedCache("/go/pkg/mod", cacheVolume("go-mod")) + .withMountedCache("/root/.cache/go-build", cacheVolume("go-build")) + .withDirectory("/helper", currentModule.source.directory("helpers/codegen")) + .withWorkdir("/helper") + .withExec(["go", "build", "-o", "/usr/local/bin/codegen", "."]) + } + + """ + Container with the config-updator helper compiled and on PATH at + /usr/local/bin/config-updator. + """ + let configUpdatorBuilder: Container! { + container + .from("golang:1.25-alpine") + .withoutEntrypoint + .withMountedCache("/go/pkg/mod", cacheVolume("go-mod")) + .withMountedCache("/root/.cache/go-build", cacheVolume("go-build")) + .withDirectory("/helper", currentModule.source.directory("helpers/config-updator")) + .withWorkdir("/helper") + .withExec(["go", "build", "-o", "/usr/local/bin/config-updator", "."]) + } + + """ + Generate the raw client bindings (client.gen.ts plus any per-dependency + .gen.ts files) from an introspection schema. + + This is the engine-free core of client generation, run in the codegen + container. `dependencies` is a JSON array of {kind, moduleOriginalName, pin, + asString} (default empty). It drives the generated serveModuleDependencies + wrapper: GIT_SOURCE deps embed source+refPin; LOCAL_SOURCE deps take the + workspace-only guarded serve. Any other kind (e.g. DIR_SOURCE) fails closed. + """ + let generateClientBindings(schemaJSON: File!, moduleName: String!, engineVersion: String!, dependencies: String! = "[]"): Directory! { + let metaJson = "{\"moduleName\":" + toJSON(moduleName) + ",\"engineVersion\":" + toJSON(engineVersion) + ",\"dependencies\":" + dependencies + "}" + + codegenBuilder + .withMountedFile("/schema.json", schemaJSON) + .withNewFile("/meta.json", metaJson) + .withExec([ + "codegen", + "--introspection-json-path", "/schema.json", + "--client-meta-path", "/meta.json", + "--output", "/out", + ]) + .directory("/out") + } + + """ + Layer the scoped-package config files (package.json, tsconfig.json) onto a set + of generated Node/Bun client bindings. + + `existing` supplies the client dir's current config so user customizations are + preserved; pass an empty directory for a brand-new client. `package.json` gets + type=module, an SDK-owned @dagger.io/dagger pinned to `engineVersion`, a + typescript pin, and a scoped name derived from `moduleName` when unnamed. + """ + let configureClientNode(bindings: Directory!, existing: Directory!, engineVersion: String!, moduleName: String!): Directory! { + configUpdatorBuilder + .withDirectory("/out", bindings) + .withDirectory("/existing", existing) + .withExec(["config-updator", "client-package-json", "/existing/package.json", "/out/package.json", engineVersion, moduleName]) + .withExec(["config-updator", "client-tsconfig", "/existing/tsconfig.json", "/out/tsconfig.json"]) + .directory("/out") + } + + """ + Serialize a module source's dependencies to the JSON array the codegen helper + expects: [{kind, moduleOriginalName, pin, asString}]. Empty deps yield "[]". + """ + let dependenciesJSON(modSrc: ModuleSource!): String! { + "[" + modSrc.dependencies.{{kind, moduleOriginalName, pin, asString}}.map { d => + "{\"kind\":" + toJSON(d.kind) + + ",\"moduleOriginalName\":" + toJSON(d.moduleOriginalName) + + ",\"pin\":" + toJSON(d.pin) + + ",\"asString\":" + toJSON(d.asString) + "}" + }.join(",") + "]" + } + + """ + Build the complete Node/Bun client directory (bindings + scoped package.json + + tsconfig) for a resolved module source. + + Reads the client-facing introspection schema, dependency provenance, name, and + engine version straight off `modSrc`. `existing` supplies the client dir's + current config so user edits are preserved; pass an empty directory for a fresh + client. + """ + let clientDirectory(modSrc: ModuleSource!, existing: Directory!): Directory! { + let bindings = generateClientBindings( + modSrc.introspectionSchemaJSON, + modSrc.moduleOriginalName, + modSrc.engineVersion, + dependenciesJSON(modSrc) + ) + configureClientNode(bindings, existing, modSrc.engineVersion, modSrc.moduleOriginalName) + } + + """ + Generate a typed client for `module` and stage it at workspace-relative `path`. + + The workspace analogue of `mod(ws, path).generate(ws)`: it resolves the bound + module in the workspace, generates the client from its client-facing schema, + and returns the staged changes. + """ + pub generateClient(ws: Workspace!, module: String!, path: String!): Changeset! { + let pws = polyfill.workspace(ws) + let modSrc = pws.moduleSource(module).core + pws.fork.withDirectory(path, clientDirectory(modSrc, directory)).changes + } + """ Generate all discovered legacy dagger.json TypeScript SDK modules. @@ -292,7 +411,7 @@ type TypescriptSdk { Modules with the generate skip marker are skipped. """ - pub generateAll(ws: Workspace!): Changeset! @generate { + pub generateAllModule(ws: Workspace!): Changeset! @generate { let pws = polyfill.workspace(ws) currentModule.asSDK.modules @@ -304,4 +423,26 @@ type TypescriptSdk { } .changes } + + """ + Regenerate every client this SDK manages in the workspace. + + Iterates the workspace-registered clients (currentModule.asSDK.clients), + resolves each bound module through the engine, generates the client from its + client-facing introspection schema, and stages the files at the client path. + + Note: depends on the engine primitives CurrentModuleAsSDKClient.moduleSource + and ModuleSource.clientSchemaIntrospectionJSON (gated v1.0.0-0); until a + running engine exposes them this loads but fails when called. + """ + pub generateAllClient(ws: Workspace!): Changeset! @generate { + let pws = polyfill.workspace(ws) + + currentModule.asSDK.clients + .{{path, module}} + .reduce(pws.fork) { fork, client => + fork.withDirectory(client.path, clientDirectory(pws.moduleSource(client.module).core, directory)) + } + .changes + } }