@workos/oagen-emitters 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +28 -0
  3. package/dist/index.d.mts +4 -1
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +2 -2
  6. package/dist/{plugin-H0KhxbN7.mjs → plugin-C408Wh-o.mjs} +2632 -717
  7. package/dist/plugin-C408Wh-o.mjs.map +1 -0
  8. package/dist/plugin.d.mts.map +1 -1
  9. package/dist/plugin.mjs +1 -1
  10. package/docs/sdk-architecture/rust.md +323 -0
  11. package/package.json +2 -2
  12. package/src/go/models.ts +48 -3
  13. package/src/index.ts +1 -0
  14. package/src/php/models.ts +27 -3
  15. package/src/php/resources.ts +16 -16
  16. package/src/plugin.ts +2 -1
  17. package/src/python/enums.ts +11 -54
  18. package/src/python/models.ts +204 -219
  19. package/src/python/path-expression.ts +75 -26
  20. package/src/python/resources.ts +19 -44
  21. package/src/python/shared-schemas.ts +488 -0
  22. package/src/python/tests.ts +9 -7
  23. package/src/ruby/resources.ts +13 -1
  24. package/src/rust/client.ts +62 -0
  25. package/src/rust/enums.ts +201 -0
  26. package/src/rust/fixtures.ts +110 -0
  27. package/src/rust/index.ts +95 -0
  28. package/src/rust/manifest.ts +31 -0
  29. package/src/rust/models.ts +150 -0
  30. package/src/rust/naming.ts +131 -0
  31. package/src/rust/resources.ts +689 -0
  32. package/src/rust/secret.ts +59 -0
  33. package/src/rust/tests.ts +298 -0
  34. package/src/rust/type-map.ts +225 -0
  35. package/test/entrypoint.test.ts +1 -0
  36. package/test/go/models.test.ts +116 -1
  37. package/test/go/resources.test.ts +70 -0
  38. package/test/php/models.test.ts +77 -0
  39. package/test/php/resources.test.ts +95 -0
  40. package/test/plugin.test.ts +2 -1
  41. package/test/python/enums.test.ts +91 -0
  42. package/test/python/models.test.ts +225 -0
  43. package/test/python/resources.test.ts +47 -2
  44. package/test/ruby/resources.test.ts +58 -0
  45. package/test/rust/client.test.ts +62 -0
  46. package/test/rust/enums.test.ts +117 -0
  47. package/test/rust/manifest.test.ts +73 -0
  48. package/test/rust/models.test.ts +139 -0
  49. package/test/rust/resources.test.ts +245 -0
  50. package/test/rust/type-map.test.ts +83 -0
  51. package/dist/plugin-H0KhxbN7.mjs.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;cAyBa,oBAAA,EAAsB,IAAA,CAAK,WAAA"}
1
+ {"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;cA0Ba,oBAAA,EAAsB,IAAA,CAAK,WAAA"}
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-H0KhxbN7.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-C408Wh-o.mjs";
2
2
  export { workosEmittersPlugin };
@@ -0,0 +1,323 @@
1
+ # Rust SDK Architecture
2
+
3
+ Scenario B (fresh) — no backwards-compatibility constraints.
4
+ Reference idioms drawn from popular Rust SDK ecosystems (e.g., `octocrab`, `stripe-rust`,
5
+ `aws-sdk-rust`) — async-first with `reqwest` + `tokio`, `serde` for serialization,
6
+ `thiserror` for the error hierarchy.
7
+
8
+ ## Architecture Overview
9
+
10
+ Single crate (e.g., `workos`). All public types are re-exported from the crate root via
11
+ `src/lib.rs`. Consumers use `workos::Client::new(api_key)` and call resource methods like
12
+ `client.organizations().create_organization(params).await?`.
13
+
14
+ - **Client**: `Client` struct holding an `Arc<ClientInner>` so it is cheap to clone and
15
+ share across tasks. Constructed via `Client::new(api_key)` or `Client::builder()`.
16
+ - **Resources**: Per-mount-group handle structs (e.g., `OrganizationsResource<'a>`)
17
+ borrowing the `Client` and exposing methods.
18
+ - **Models**: `#[derive(Debug, Clone, Serialize, Deserialize)]` structs with
19
+ `#[serde(rename_all = "snake_case")]` and per-field `#[serde(rename = "...")]` when the
20
+ IR field name does not match the Rust ident.
21
+ - **Enums**: Real Rust `enum`s with `#[serde(rename_all = "snake_case")]` (or per-variant
22
+ `#[serde(rename = "...")]`) — not string constants.
23
+ - **Params**: One `*Params` struct per operation with `#[serde(skip_serializing_if = "Option::is_none")]`
24
+ on every optional field. Builders are not generated by default; users construct with
25
+ struct-init syntax or `..Default::default()`.
26
+ - **Errors**: Single `Error` enum derived with `thiserror::Error` covering `Api`,
27
+ `Network`, `Decode`, `Builder`, and per-status convenience variants.
28
+ - **Pagination**: A `Pagination<T>` page wrapper plus an async stream iterator
29
+ (`futures::Stream`) for auto-paging.
30
+ - **HTTP**: `reqwest::Client` (async), JSON via `serde_json`, retries with exponential
31
+ backoff on 429/5xx (configurable on the client builder).
32
+
33
+ ## Naming Conventions
34
+
35
+ | IR Name | Rust Name | Context |
36
+ | -------------------------- | ------------------------ | ------------------------------------------------ |
37
+ | `Organization` (model) | `Organization` | Struct type |
38
+ | `organization` (file) | `organization.rs` | Module file (snake_case) |
39
+ | `listUsers` (method) | `list_users` | Method (snake_case) |
40
+ | `user_id` (field) | `user_id` | Struct field (snake_case, no acronym hacks) |
41
+ | `Status` (enum) | `Status` | Enum type |
42
+ | `active` (enum value) | `Status::Active` | Variant: PascalCase |
43
+ | `Organizations` (service) | `OrganizationsResource` | Resource handle struct |
44
+ | `organizations` (accessor) | `client.organizations()` | Method on `Client` returning the resource handle |
45
+
46
+ ### Casing rules
47
+
48
+ Rust convention: types and enum variants `PascalCase`, fields/methods/modules
49
+ `snake_case`, constants `SCREAMING_SNAKE_CASE`. Acronyms are _not_ preserved — `ID` →
50
+ `id`, `URL` → `url`, `JWT` → `jwt` — matching the standard library and `serde` ecosystem.
51
+
52
+ ## Type Mapping
53
+
54
+ | IR TypeRef | Rust Type |
55
+ | ------------------------------- | ------------------------------------------------------------------------------------- |
56
+ | `primitive:string` | `String` |
57
+ | `primitive:string:date` | `String` |
58
+ | `primitive:string:date-time` | `String` |
59
+ | `primitive:string:uuid` | `String` |
60
+ | `primitive:string:binary` | `Vec<u8>` |
61
+ | `primitive:integer` | `i64` |
62
+ | `primitive:integer:int32` | `i32` |
63
+ | `primitive:number` | `f64` |
64
+ | `primitive:boolean` | `bool` |
65
+ | `primitive:unknown` | `serde_json::Value` |
66
+ | `array<T>` | `Vec<T>` |
67
+ | `map<V>` | `std::collections::HashMap<String, V>` |
68
+ | `nullable<T>` | `Option<T>` |
69
+ | `model:Foo` | `Foo` |
70
+ | `enum:Bar` | `Bar` |
71
+ | `union<A,B>` (discriminated) | Rust enum `Either { A(A), B(B) }` with `#[serde(tag = "...")]` |
72
+ | `union<A,B>` (no discriminator) | `#[serde(untagged)]` enum |
73
+ | `literal:"foo"` | `String` (default; literal preserved in serde rename only when used as discriminator) |
74
+
75
+ Optional fields (`required: false`) are wrapped in `Option<...>` independent of nullability.
76
+
77
+ ## Model Pattern
78
+
79
+ ```rust
80
+ use serde::{Deserialize, Serialize};
81
+
82
+ #[derive(Debug, Clone, Serialize, Deserialize)]
83
+ pub struct Organization {
84
+ pub id: String,
85
+ pub name: String,
86
+ #[serde(skip_serializing_if = "Option::is_none")]
87
+ pub domain: Option<String>,
88
+ pub created_at: String,
89
+ }
90
+ ```
91
+
92
+ - `Debug` + `Clone` always derived.
93
+ - `Serialize` + `Deserialize` always derived.
94
+ - Optional fields skip serialization when `None` to avoid sending `null` for absent
95
+ values.
96
+
97
+ ## Enum Pattern
98
+
99
+ ```rust
100
+ use serde::{Deserialize, Serialize};
101
+
102
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103
+ #[serde(rename_all = "snake_case")]
104
+ pub enum Status {
105
+ Active,
106
+ Inactive,
107
+ Pending,
108
+ }
109
+ ```
110
+
111
+ When raw values do not match `snake_case` lowercase variants, per-variant
112
+ `#[serde(rename = "exact_value")]` is emitted. `Copy` is derived only when all variants
113
+ are unit (no payload).
114
+
115
+ ## Resource / Client Pattern
116
+
117
+ ```rust
118
+ // src/client.rs
119
+ use std::sync::Arc;
120
+
121
+ #[derive(Clone)]
122
+ pub struct Client {
123
+ inner: Arc<ClientInner>,
124
+ }
125
+
126
+ pub(crate) struct ClientInner {
127
+ pub(crate) http: reqwest::Client,
128
+ pub(crate) base_url: String,
129
+ pub(crate) api_key: String,
130
+ }
131
+
132
+ impl Client {
133
+ pub fn new(api_key: impl Into<String>) -> Self {
134
+ Self::builder().api_key(api_key).build()
135
+ }
136
+
137
+ pub fn builder() -> ClientBuilder { ClientBuilder::default() }
138
+
139
+ pub fn organizations(&self) -> OrganizationsResource<'_> {
140
+ OrganizationsResource { client: self }
141
+ }
142
+ }
143
+ ```
144
+
145
+ ```rust
146
+ // src/resources/organizations.rs
147
+ pub struct OrganizationsResource<'a> {
148
+ pub(crate) client: &'a Client,
149
+ }
150
+
151
+ impl OrganizationsResource<'_> {
152
+ pub async fn create_organization(
153
+ &self,
154
+ params: CreateOrganizationParams,
155
+ ) -> Result<Organization, Error> {
156
+ self.client.request_json(
157
+ reqwest::Method::POST,
158
+ "/organizations",
159
+ Some(&params),
160
+ ).await
161
+ }
162
+ }
163
+ ```
164
+
165
+ - Resource methods are `async fn` and return `Result<T, Error>`.
166
+ - Path parameters are positional Rust args; query/body parameters are folded into a
167
+ single `*Params` struct passed by value.
168
+ - The shared HTTP plumbing (`request_json`, `request_empty`, query string serialization,
169
+ retry) lives on `ClientInner` so resources stay thin.
170
+
171
+ ## Pagination Pattern
172
+
173
+ ```rust
174
+ #[derive(Debug, Clone, Deserialize)]
175
+ pub struct Page<T> {
176
+ pub data: Vec<T>,
177
+ pub list_metadata: ListMetadata,
178
+ }
179
+
180
+ #[derive(Debug, Clone, Deserialize)]
181
+ pub struct ListMetadata {
182
+ pub before: Option<String>,
183
+ pub after: Option<String>,
184
+ }
185
+ ```
186
+
187
+ A future iteration may add a `futures::Stream`-based auto-pager; v1 returns a single
188
+ page and lets callers loop on `list_metadata.after`.
189
+
190
+ ## Error Pattern
191
+
192
+ ```rust
193
+ use thiserror::Error;
194
+
195
+ #[derive(Debug, Error)]
196
+ pub enum Error {
197
+ #[error("API error {status}: {message}")]
198
+ Api {
199
+ status: u16,
200
+ code: Option<String>,
201
+ message: String,
202
+ },
203
+ #[error("network error: {0}")]
204
+ Network(#[from] reqwest::Error),
205
+ #[error("decode error: {0}")]
206
+ Decode(#[from] serde_json::Error),
207
+ #[error("invalid request: {0}")]
208
+ Builder(String),
209
+ }
210
+ ```
211
+
212
+ Status-specific helpers (`is_unauthorized()`, `is_not_found()`, `is_rate_limited()`) are
213
+ provided as inherent methods on `Error`. The `Api` variant always carries the raw HTTP
214
+ status so callers can match on numeric ranges.
215
+
216
+ ## Retry Logic
217
+
218
+ Retries live on `ClientInner::request`:
219
+
220
+ - Retry on 429 and 5xx status codes.
221
+ - Exponential backoff with jitter: `base * 2^n + rand(0..jitter)`.
222
+ - Defaults: `max_retries = 3`, `base = 100ms`, `max_delay = 5s` (overridable on
223
+ `ClientBuilder`).
224
+ - Network errors (`reqwest::Error::is_connect()` / `is_timeout()`) are also retried.
225
+
226
+ ## Test Pattern
227
+
228
+ ```rust
229
+ #[cfg(test)]
230
+ mod tests {
231
+ use super::*;
232
+ use wiremock::matchers::{method, path};
233
+ use wiremock::{Mock, MockServer, ResponseTemplate};
234
+
235
+ #[tokio::test]
236
+ async fn create_organization_success() {
237
+ let server = MockServer::start().await;
238
+ let body = include_str!("../tests/fixtures/organization.json");
239
+ Mock::given(method("POST")).and(path("/organizations"))
240
+ .respond_with(ResponseTemplate::new(200).set_body_string(body))
241
+ .mount(&server).await;
242
+
243
+ let client = Client::builder()
244
+ .api_key("test")
245
+ .base_url(server.uri())
246
+ .build();
247
+ let org = client.organizations()
248
+ .create_organization(CreateOrganizationParams { name: "Acme".into(), ..Default::default() })
249
+ .await.unwrap();
250
+
251
+ assert_eq!(org.name, "Acme");
252
+ }
253
+ }
254
+ ```
255
+
256
+ Tests use the built-in `cargo test` harness with `#[tokio::test]` for async cases and
257
+ `wiremock` for HTTP mocking. JSON fixtures live under `tests/fixtures/` and are pulled in
258
+ with `include_str!` so no I/O is required at test time.
259
+
260
+ ## Structural Guidelines
261
+
262
+ | Concern | Choice |
263
+ | --------------------- | -------------------------------------------------------- |
264
+ | Edition | `2024` (falls back to `2021` if compiler too old) |
265
+ | HTTP client | `reqwest` (async, default features minus blocking) |
266
+ | Async runtime | `tokio` 1.x with `rt-multi-thread` and `macros` features |
267
+ | JSON | `serde` + `serde_json` |
268
+ | Error derive | `thiserror` |
269
+ | Stream / future utils | `futures` (only when paginated streams are emitted) |
270
+ | Test framework | `cargo test` (built-in) |
271
+ | HTTP mocking | `wiremock` (dev-dependency) |
272
+ | Async tests | `#[tokio::test]` |
273
+ | Linting | `cargo clippy -- -D warnings` |
274
+ | Formatting | `cargo fmt` |
275
+ | Package manager | `cargo` |
276
+ | Build tool | `cargo` |
277
+ | Documentation | Rustdoc (`///` doc comments) |
278
+
279
+ ## Directory Structure
280
+
281
+ ```
282
+ {output}/
283
+ ├── Cargo.toml
284
+ ├── README.md (placeholder; only emitted if missing)
285
+ ├── src/
286
+ │ ├── lib.rs (crate root: re-exports public surface, doc-comment, prelude)
287
+ │ ├── client.rs (Client, ClientBuilder, ClientInner, request plumbing)
288
+ │ ├── error.rs (Error enum + helpers)
289
+ │ ├── models/
290
+ │ │ ├── mod.rs (re-exports each model module)
291
+ │ │ ├── organization.rs
292
+ │ │ └── ...
293
+ │ ├── enums/
294
+ │ │ ├── mod.rs
295
+ │ │ ├── status.rs
296
+ │ │ └── ...
297
+ │ ├── resources/
298
+ │ │ ├── mod.rs (re-exports each resource handle)
299
+ │ │ ├── organizations.rs
300
+ │ │ └── ...
301
+ │ └── pagination.rs (Page<T>, ListMetadata)
302
+ └── tests/
303
+ ├── common/
304
+ │ └── mod.rs (shared test helpers, fixture loader)
305
+ ├── fixtures/
306
+ │ ├── organization.json
307
+ │ └── ...
308
+ └── organizations_test.rs
309
+ ```
310
+
311
+ One file per model and one file per resource. The crate root (`lib.rs`) is the public
312
+ surface that the `rust` extractor reads.
313
+
314
+ ## Auto-generated File Header
315
+
316
+ Every generated file begins with:
317
+
318
+ ```rust
319
+ // Code generated by oagen. DO NOT EDIT.
320
+ ```
321
+
322
+ `Cargo.toml` uses the TOML-style equivalent (`# Code generated by oagen. DO NOT EDIT.`)
323
+ and JSON fixtures skip the header (`headerPlacement: 'skip'`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -54,6 +54,6 @@
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.18.0"
57
+ "@workos/oagen": "^0.18.1"
58
58
  }
59
59
  }
package/src/go/models.ts CHANGED
@@ -198,7 +198,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
198
198
  lines.push('');
199
199
  }
200
200
 
201
- // Emit shared PaginationParams struct for list operations to embed
201
+ // Emit shared PaginationParams struct for list operations to embed.
202
+ //
203
+ // The Order field's type is derived from the spec rather than hardcoded:
204
+ // when every paginated `order` query parameter $refs the same top-level
205
+ // enum (typically `PaginationOrder` in the WorkOS spec), we emit the typed
206
+ // enum so callers get compile-time validation. Otherwise we fall back to
207
+ // *string. The fallback handles older specs that don't lift the enum into a
208
+ // named component schema.
209
+ const orderEnumType = detectSharedOrderEnum(ctx.spec.services);
210
+ const orderGoType = orderEnumType ? `*${className(orderEnumType)}` : '*string';
202
211
  lines.push('// PaginationParams contains common pagination parameters for list operations.');
203
212
  lines.push('type PaginationParams struct {');
204
213
  lines.push('\t// Before is a cursor for reverse pagination.');
@@ -207,8 +216,8 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
207
216
  lines.push('\tAfter *string `url:"after,omitempty" json:"-"`');
208
217
  lines.push('\t// Limit is the maximum number of items to return per page.');
209
218
  lines.push('\tLimit *int `url:"limit,omitempty" json:"-"`');
210
- lines.push('\t// Order is the sort order for results (asc or desc).');
211
- lines.push('\tOrder *string `url:"order,omitempty" json:"-"`');
219
+ lines.push('\t// Order is the sort order for results.');
220
+ lines.push(`\tOrder ${orderGoType} \`url:"order,omitempty" json:"-"\``);
212
221
  lines.push('}');
213
222
  lines.push('');
214
223
 
@@ -311,6 +320,42 @@ function lowerFirst(s: string): string {
311
320
  return lowerFirstForDoc(s);
312
321
  }
313
322
 
323
+ /**
324
+ * If every paginated list operation's `order` query parameter $refs the same
325
+ * top-level enum, return that enum's IR name. Otherwise return null. When
326
+ * the spec is consistent this lifts `PaginationParams.Order` from `*string`
327
+ * to `*PaginationOrder` (or whatever the spec calls it), giving callers
328
+ * compile-time validation.
329
+ *
330
+ * We require strict consistency: if any operation uses a primitive string for
331
+ * `order`, or two operations reference different enums, we conservatively
332
+ * stay on `*string` so the shared struct doesn't lie about its accepted
333
+ * values.
334
+ */
335
+ function detectSharedOrderEnum(services: Service[]): string | null {
336
+ let candidate: string | null = null;
337
+ let sawAny = false;
338
+ for (const service of services) {
339
+ for (const op of service.operations) {
340
+ if (!op.pagination) continue;
341
+ const orderParam = op.queryParams.find((p) => p.name === 'order');
342
+ if (!orderParam) continue;
343
+ sawAny = true;
344
+ const enumName = unwrapEnumName(orderParam.type);
345
+ if (!enumName) return null;
346
+ if (candidate === null) candidate = enumName;
347
+ else if (candidate !== enumName) return null;
348
+ }
349
+ }
350
+ return sawAny ? candidate : null;
351
+ }
352
+
353
+ function unwrapEnumName(ref: TypeRef): string | null {
354
+ if (ref.kind === 'enum') return ref.name;
355
+ if (ref.kind === 'nullable') return unwrapEnumName(ref.inner);
356
+ return null;
357
+ }
358
+
314
359
  /**
315
360
  * Extract a deprecation reason from a field description.
316
361
  * Looks for patterns like "Use X instead", "Replaced by Y", etc.
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export { goEmitter } from './go/index.js';
5
5
  export { dotnetEmitter } from './dotnet/index.js';
6
6
  export { kotlinEmitter } from './kotlin/index.js';
7
7
  export { rubyEmitter } from './ruby/index.js';
8
+ export { rustEmitter } from './rust/index.js';
8
9
 
9
10
  export { nodeExtractor } from './compat/extractors/node.js';
10
11
  export { rubyExtractor } from './compat/extractors/ruby.js';
package/src/php/models.ts CHANGED
@@ -221,15 +221,19 @@ function generateFromArrayValue(ref: TypeRef, accessor: string): string {
221
221
  return generateFromArrayValue(ref.inner, accessor);
222
222
  case 'union': {
223
223
  // Discriminated union: dispatch via match() on the discriminator
224
- // property to call the matching variant's fromArray. Unknown values
225
- // pass through as raw arrays so callers can introspect.
224
+ // property to call the matching variant's fromArray. An unknown
225
+ // discriminator value would otherwise assign a raw array to a typed
226
+ // property and crash later with a confusing TypeError, so throw
227
+ // immediately with the offending value.
226
228
  if (ref.discriminator && ref.discriminator.mapping) {
227
229
  const entries = Object.entries(ref.discriminator.mapping);
228
230
  if (entries.length > 0) {
229
231
  const arms = entries
230
232
  .map(([value, modelName]) => `'${value}' => ${className(modelName)}::fromArray(${accessor})`)
231
233
  .join(', ');
232
- return `match (${accessor}['${ref.discriminator.property}'] ?? null) { ${arms}, default => ${accessor} }`;
234
+ const discProp = ref.discriminator.property;
235
+ const throwArm = `default => throw new \\UnexpectedValueException(sprintf('Unknown ${discProp}: %s', json_encode(${accessor}['${discProp}'] ?? null)))`;
236
+ return `match (${accessor}['${discProp}'] ?? null) { ${arms}, ${throwArm} }`;
233
237
  }
234
238
  }
235
239
  const resolved = resolveDegenerateUnion(ref);
@@ -313,6 +317,26 @@ function generateToArrayValue(ref: TypeRef, accessor: string, nullable = false):
313
317
  case 'union': {
314
318
  const resolved = resolveDegenerateUnion(ref);
315
319
  if (resolved) return generateToArrayValue(resolved, accessor, nullable);
320
+ // Polymorphic union of model variants: PHP dispatches to the concrete
321
+ // instance's toArray() at runtime, so a single ->toArray() call serializes
322
+ // any branch correctly without a match here.
323
+ if (ref.variants.every((v) => v.kind === 'model')) {
324
+ return `${accessor}${ns}->toArray()`;
325
+ }
326
+ // Heterogeneous unions involving models or enums have no uniform
327
+ // serialization strategy (->toArray() vs ->value vs raw scalar), so fail
328
+ // at codegen time rather than silently emitting a raw object that breaks
329
+ // the toArray contract. Pure scalar unions (e.g. string|int) fall
330
+ // through to the bare accessor below — that is correct.
331
+ if (ref.variants.some((v) => v.kind === 'model' || v.kind === 'enum')) {
332
+ const summary = ref.variants
333
+ .map((v) => (v.kind === 'model' ? `model:${v.name}` : v.kind === 'enum' ? `enum:${v.name}` : v.kind))
334
+ .join(' | ');
335
+ throw new Error(
336
+ `[php emitter] Cannot generate toArray for heterogeneous union: ${summary}. ` +
337
+ `Unions must be all-model or all-scalar; mixed and all-enum unions are not yet supported.`,
338
+ );
339
+ }
316
340
  return accessor;
317
341
  }
318
342
  case 'literal':
@@ -301,9 +301,10 @@ function generateMethod(
301
301
  const phpName = fieldName(q.name);
302
302
  if (seenDocParams.has(phpName)) continue;
303
303
  seenDocParams.add(phpName);
304
- // order params with enum defaults are non-nullable (they default to Desc, not null)
305
- const isNonNullableOrder = q.name === 'order' && q.type.kind === 'enum';
306
- const nullSuffix = !q.required && !isNonNullableOrder && !docType.endsWith('|null') ? '|null' : '';
304
+ // Spec-defaulted enum params are non-nullable (the signature default is the
305
+ // enum case, never null). Without a spec default, the param is nullable.
306
+ const hasEnumDefault = q.default != null && q.type.kind === 'enum';
307
+ const nullSuffix = !q.required && !hasEnumDefault && !docType.endsWith('|null') ? '|null' : '';
307
308
  const prefix = q.deprecated ? '(deprecated) ' : '';
308
309
  let desc = q.description ? ` ${prefix}${q.description}` : q.deprecated ? ' (deprecated)' : '';
309
310
  if (q.default != null) desc += ` Defaults to ${JSON.stringify(q.default)}.`;
@@ -682,16 +683,14 @@ function buildMethodParams(
682
683
  usedNames.add(phpName);
683
684
  if (q.required) {
684
685
  required.push(`${phpType} $${phpName}`);
685
- } else if (q.name === 'order') {
686
- // Hardcode order default to desc for pagination consistency
687
- if (q.type.kind === 'enum') {
688
- const enumType = mapTypeRef(q.type, { qualified: true });
689
- const caseName = toPascalCase('desc');
690
- optional.push(`${enumType} $${phpName} = ${enumType}::${caseName}`);
691
- } else {
692
- const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
693
- optional.push(`${nullableType} $${phpName} = 'desc'`);
694
- }
686
+ } else if (q.default != null && q.type.kind === 'enum') {
687
+ // Spec-provided default for an enum-typed param: emit a non-nullable
688
+ // typed default (e.g. PaginationOrder $order = PaginationOrder::Desc).
689
+ // Only enums are safe to default this way — primitives stay nullable so
690
+ // callers can distinguish "unset" from "explicit value".
691
+ const enumType = mapTypeRef(q.type, { qualified: true });
692
+ const caseName = toPascalCase(String(q.default));
693
+ optional.push(`${enumType} $${phpName} = ${enumType}::${caseName}`);
695
694
  } else {
696
695
  const nullableType = phpType.startsWith('?') ? phpType : `?${phpType}`;
697
696
  optional.push(`${nullableType} $${phpName} = null`);
@@ -756,9 +755,10 @@ function buildQueryArray(op: Operation, hiddenParams?: Set<string>): string[] {
756
755
  .map((q) => {
757
756
  const phpName = fieldName(q.name);
758
757
  if (isEnumType(q.type)) {
759
- // order params with enum defaults are non-nullable (default to Desc, not null)
760
- const isNonNullableOrder = q.name === 'order' && q.type.kind === 'enum';
761
- const nullsafe = q.required || isNonNullableOrder ? '' : '?';
758
+ // Mirrors the signature: enum params with a spec default are
759
+ // non-nullable, so we can dereference ->value without the nullsafe op.
760
+ const hasEnumDefault = q.default != null && q.type.kind === 'enum';
761
+ const nullsafe = q.required || hasEnumDefault ? '' : '?';
762
762
  return `'${q.name}' => $${phpName}${nullsafe}->value,`;
763
763
  }
764
764
  return `'${q.name}' => $${phpName},`;
package/src/plugin.ts CHANGED
@@ -8,6 +8,7 @@ import { goEmitter } from './go/index.js';
8
8
  import { dotnetEmitter } from './dotnet/index.js';
9
9
  import { kotlinEmitter } from './kotlin/index.js';
10
10
  import { rubyEmitter } from './ruby/index.js';
11
+ import { rustEmitter } from './rust/index.js';
11
12
  import { nodeExtractor } from './compat/extractors/node.js';
12
13
  import { rubyExtractor } from './compat/extractors/ruby.js';
13
14
  import { pythonExtractor } from './compat/extractors/python.js';
@@ -24,7 +25,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
25
  const smokeDir = path.resolve(__dirname, '..', 'smoke');
25
26
 
26
27
  export const workosEmittersPlugin: Pick<OagenConfig, 'emitters' | 'extractors' | 'smokeRunners'> = {
27
- emitters: [nodeEmitter, pythonEmitter, phpEmitter, goEmitter, dotnetEmitter, kotlinEmitter, rubyEmitter],
28
+ emitters: [nodeEmitter, pythonEmitter, phpEmitter, goEmitter, dotnetEmitter, kotlinEmitter, rubyEmitter, rustEmitter],
28
29
  extractors: [
29
30
  nodeExtractor,
30
31
  rubyExtractor,
@@ -1,6 +1,7 @@
1
- import type { Enum, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
- import { toUpperSnakeCase, walkTypeRef } from '@workos/oagen';
1
+ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { toUpperSnakeCase } from '@workos/oagen';
3
3
  import { className, fileName, buildMountDirMap, dirToModule } from './naming.js';
4
+ import { computeSchemaPlacement } from './shared-schemas.js';
4
5
 
5
6
  /**
6
7
  * Convert a PascalCase class name to a human-readable lowercase string,
@@ -21,14 +22,18 @@ function humanizeClassName(name: string): string {
21
22
  export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
22
23
  if (enums.length === 0) return [];
23
24
 
24
- const enumToService = assignEnumsToServices(enums, ctx.spec.services);
25
+ // Tests sometimes pass enums that aren't in ctx.spec.enums, so synthesize a
26
+ // spec view with the passed-in enums to keep the placement logic accurate.
27
+ const placementSpec = enums === ctx.spec.enums ? ctx.spec : { ...ctx.spec, enums };
28
+ const placement = computeSchemaPlacement(placementSpec, ctx);
29
+ const enumToService = placement.enumToService;
25
30
  const mountDirMap = buildMountDirMap(ctx);
26
31
  const resolveDir = (irService: string | undefined) =>
27
32
  irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
28
33
  const files: GeneratedFile[] = [];
29
34
  const compatAliases = collectCompatEnumAliases(enums, ctx);
30
35
 
31
- const aliasOf = collectEnumAliasOf(enums);
36
+ const aliasOf = placement.enumAliases;
32
37
 
33
38
  for (const enumDef of enums) {
34
39
  const service = enumToService.get(enumDef.name);
@@ -260,31 +265,9 @@ export function collectCompatEnumAliases(enums: Enum[], ctx: EmitterContext): Ma
260
265
  return aliases;
261
266
  }
262
267
 
263
- function collectEnumAliasOf(enums: Enum[]): Map<string, string> {
264
- const hashGroups = new Map<string, string[]>();
265
- for (const enumDef of enums) {
266
- const hash = [...enumDef.values]
267
- .map((v) => String(v.value))
268
- .sort()
269
- .join('|');
270
- if (!hashGroups.has(hash)) hashGroups.set(hash, []);
271
- hashGroups.get(hash)!.push(enumDef.name);
272
- }
273
-
274
- const aliasOf = new Map<string, string>();
275
- for (const [, names] of hashGroups) {
276
- if (names.length <= 1) continue;
277
- const sorted = [...names].sort();
278
- const canonical = sorted[0];
279
- for (let i = 1; i < sorted.length; i++) {
280
- aliasOf.set(sorted[i], canonical);
281
- }
282
- }
283
- return aliasOf;
284
- }
285
-
286
268
  export function collectGeneratedEnumSymbolsByDir(enums: Enum[], ctx: EmitterContext): Map<string, string[]> {
287
- const enumToService = assignEnumsToServices(enums, ctx.spec.services);
269
+ const placementSpec = enums === ctx.spec.enums ? ctx.spec : { ...ctx.spec, enums };
270
+ const enumToService = computeSchemaPlacement(placementSpec, ctx).enumToService;
288
271
  const mountDirMap = buildMountDirMap(ctx);
289
272
  const resolveDir = (irService: string | undefined) =>
290
273
  irService ? (mountDirMap.get(irService) ?? 'common') : 'common';
@@ -310,29 +293,3 @@ function enumValueHash(enumDef: Enum): string {
310
293
  .sort()
311
294
  .join('|');
312
295
  }
313
-
314
- export function assignEnumsToServices(enums: Enum[], services: Service[]): Map<string, string> {
315
- const enumToService = new Map<string, string>();
316
- const enumNames = new Set(enums.map((e) => e.name));
317
-
318
- for (const service of services) {
319
- for (const op of service.operations) {
320
- const refs = new Set<string>();
321
- const collect = (ref: any) => {
322
- walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
323
- };
324
- if (op.requestBody) collect(op.requestBody);
325
- collect(op.response);
326
- for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams, ...(op.cookieParams ?? [])]) {
327
- collect(p.type);
328
- }
329
- for (const name of refs) {
330
- if (enumNames.has(name) && !enumToService.has(name)) {
331
- enumToService.set(name, service.name);
332
- }
333
- }
334
- }
335
- }
336
-
337
- return enumToService;
338
- }