Skip to content

fix(cloudflare): split account-or-zone scoped operations into per-scope variants#282

Merged
Mkassabov merged 3 commits into
mainfrom
fix/cloudflare-account-or-zone-split
May 8, 2026
Merged

fix(cloudflare): split account-or-zone scoped operations into per-scope variants#282
Mkassabov merged 3 commits into
mainfrom
fix/cloudflare-account-or-zone-split

Conversation

@Mkassabov
Copy link
Copy Markdown
Contributor

Cloudflare's API has a path pattern /{accountOrZone}/{accountOrZoneId}/... where the literal first segment is accounts or zones depending on which scope ID the caller provides. The OpenAPI spec collapses both into one entry that declares account_id and zone_id as both required path params (mutually exclusive in reality). Distilled's generator was faithfully turning this into broken code: both fields typed as required, and {accountOrZone} / {accountOrZoneId} placeholders never bound to anything, so ~85 endpoints across 7 services were uncallable.

This PR fixes those endpoints by splitting each at codegen time into two concrete operations plus a thin convenience wrapper. Supersedes #257, which used a runtime trait + throw new Error for the same problem.

Generator changes

Three independent fixes in packages/cloudflare/scripts/generate.ts:

  1. Auto-detect accountOrZone-scoped operations by URL template and split each into:

    • <op>ForAccount — concrete URL /accounts/{account_id}/..., request schema with accountId
    • <op>ForZone — concrete URL /zones/{zone_id}/..., request schema with zoneId
    • <op> — wrapper function dispatching on "accountId" in input. Returns Effect.Effect<...> (or stream.Stream<...> for paginated). Input is the discriminated union, so invalid states are unrepresentable at the type level.
    • Body fields are extracted into a non-exported <Op>BaseFields const and spread into both variants — no source-level duplication.
    • Response and error types remain singular and shared between variants.
  2. Synthesize missing URL path params. Some operations reference placeholders like {rulesetPhase} in the URL but don't declare them as parameters — these are now auto-synthesized as required string path params.

  3. Optional path params now correctly emit as Schema.optional(...) instead of being typed as required.

User-visible API

Before (broken — no way to substitute accountOrZone/accountOrZoneId, both ids typed required):

yield* cf.rulesets.putPhas({ accountId, zoneId, rulesetPhase: "...", rules: [...] });
// → PUT /{accountOrZone}/{accountOrZoneId}/rulesets/phases/.../entrypoint  (404)

After:

// Single function, exactly-one-of input enforced by the type system
yield* cf.rulesets.putPhas({ accountId: "...", rulesetPhase: "...", rules: [...] });
yield* cf.rulesets.putPhas({ zoneId:    "...", rulesetPhase: "...", rules: [...] });

// Or import the concrete variant directly for tighter tree-shaking
yield* cf.rulesets.putPhasForAccount({ accountId: "...", rulesetPhase: "...", rules: [...] });

Why split at codegen instead of runtime

Considered a runtime approach (PR #257): mark the schema with a trait, rewrite the path in transformRequestParts, throw on invalid input. Rejected because (a) the throw bypasses Effect's typed error channel — it becomes an unchecked defect rather than a typed operation error, (b) "exactly one of" is encodable in the type system and shouldn't be a runtime check, and (c) it imports Cloudflare-specific path-rewriting logic into the core client. Splitting at codegen keeps core untouched, errors flow through Effect normally, and the type system enforces the invariant.

Test coverage

Added in packages/cloudflare/test/client-api.test.ts:

  • putPhasForAccount builds /accounts/{id}/... correctly
  • putPhasForZone builds /zones/{id}/... correctly
  • Wrapper dispatches to account variant when accountId is present
  • Wrapper dispatches to zone variant when zoneId is present
  • Source-level smoke check that the wrapper uses "accountId" in input

All 7 client-api tests pass; typecheck and check are clean for both the cloudflare package and core.

Out of scope (separate issues to file)

  • Truncated upstream operationIds (getPhas, putPhas, listPhasVersions, etc.) — these come from Cloudflare's own published OpenAPI spec and need a spec patch, not a generator fix.
  • Refactoring core's buildRequestParts to handle Schema.Union natively (would let SDKs express discriminated request unions generically without codegen-time splitting).

…pe variants

Cloudflare's API has paths like /{accountOrZone}/{accountOrZoneId}/... where
the first segment is "accounts" or "zones" depending on which scope ID the
caller provides. The OpenAPI spec collapses both into one entry with
account_id and zone_id both declared required (mutually exclusive in reality).
The generator was producing uncallable code for ~85 endpoints across 7
services as a result.

This change makes the generator detect that path pattern and emit two concrete
operations (<op>ForAccount, <op>ForZone) plus a thin wrapper (<op>) that
dispatches on "accountId" in input. The wrapper's input is the discriminated
union so invalid states are unrepresentable. Body fields are extracted into a
shared per-operation BaseFields const so source size is bounded.

Also includes two adjacent generator fixes:
- synthesize URL path params declared in the URL template but missing from the
  parameters list (e.g. rulesetPhase)
- emit optional path params as Schema.optional(...) instead of treating them
  as required

Supersedes #257 which used a runtime trait + throw for the same problem.
@alchemy-version-bot
Copy link
Copy Markdown
Contributor

alchemy-version-bot Bot commented May 7, 2026

Install the packages built from this commit:

@distilled.cloud/cloudflare

bun add https://pkg.distilled.cloud/cloudflare/db50d64

Hoist the non-scope fields into a single `<Op>BaseRequest` interface and
have each variant interface extend it with just its scope field. Avoids
duplicating large body type declarations across both variants — for ops
like PutPhasRequest with multi-thousand-line bodies this cuts the emitted
file size meaningfully without changing the public type surface.
Comment on lines +458 to +460
"accountId" in input
? listAccessRulesForAccount.pages(input)
: listAccessRulesForZone.pages(input);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should probably be an error if both are provided? ambiguity?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are you calling .pages here?

what about yield* listAccessRules .items, .pages

…rZone variants

The wrapper functions (`putPhas`, etc.) and `<Op>Request` union type aliases
were convenience surface that hid which scope the call addresses behind a
runtime `"accountId" in input` check. Drop them — callers now pick
`<op>ForAccount` or `<op>ForZone` explicitly. This:

- removes ~85 wrapper functions and their union type aliases
- lets each variant remain a clean `API.OperationMethod` value with full
  type metadata (no plain-function intermediary)
- removes the conditional `Effect`/`Stream` import requirement triggered
  only by wrappers
@Mkassabov Mkassabov added this pull request to the merge queue May 8, 2026
Merged via the queue into main with commit 74f2df2 May 8, 2026
25 of 26 checks passed
@Mkassabov Mkassabov deleted the fix/cloudflare-account-or-zone-split branch May 8, 2026 00:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants