@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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +28 -0
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{plugin-H0KhxbN7.mjs → plugin-C408Wh-o.mjs} +2632 -717
- package/dist/plugin-C408Wh-o.mjs.map +1 -0
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/docs/sdk-architecture/rust.md +323 -0
- package/package.json +2 -2
- package/src/go/models.ts +48 -3
- package/src/index.ts +1 -0
- package/src/php/models.ts +27 -3
- package/src/php/resources.ts +16 -16
- package/src/plugin.ts +2 -1
- package/src/python/enums.ts +11 -54
- package/src/python/models.ts +204 -219
- package/src/python/path-expression.ts +75 -26
- package/src/python/resources.ts +19 -44
- package/src/python/shared-schemas.ts +488 -0
- package/src/python/tests.ts +9 -7
- package/src/ruby/resources.ts +13 -1
- package/src/rust/client.ts +62 -0
- package/src/rust/enums.ts +201 -0
- package/src/rust/fixtures.ts +110 -0
- package/src/rust/index.ts +95 -0
- package/src/rust/manifest.ts +31 -0
- package/src/rust/models.ts +150 -0
- package/src/rust/naming.ts +131 -0
- package/src/rust/resources.ts +689 -0
- package/src/rust/secret.ts +59 -0
- package/src/rust/tests.ts +298 -0
- package/src/rust/type-map.ts +225 -0
- package/test/entrypoint.test.ts +1 -0
- package/test/go/models.test.ts +116 -1
- package/test/go/resources.test.ts +70 -0
- package/test/php/models.test.ts +77 -0
- package/test/php/resources.test.ts +95 -0
- package/test/plugin.test.ts +2 -1
- package/test/python/enums.test.ts +91 -0
- package/test/python/models.test.ts +225 -0
- package/test/python/resources.test.ts +47 -2
- package/test/ruby/resources.test.ts +58 -0
- package/test/rust/client.test.ts +62 -0
- package/test/rust/enums.test.ts +117 -0
- package/test/rust/manifest.test.ts +73 -0
- package/test/rust/models.test.ts +139 -0
- package/test/rust/resources.test.ts +245 -0
- package/test/rust/type-map.test.ts +83 -0
- package/dist/plugin-H0KhxbN7.mjs.map +0 -1
package/dist/plugin.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;
|
|
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-
|
|
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(¶ms),
|
|
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.
|
|
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.
|
|
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
|
|
211
|
-
lines.push(
|
|
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.
|
|
225
|
-
//
|
|
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
|
-
|
|
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':
|
package/src/php/resources.ts
CHANGED
|
@@ -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
|
-
//
|
|
305
|
-
|
|
306
|
-
const
|
|
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.
|
|
686
|
-
//
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
//
|
|
760
|
-
|
|
761
|
-
const
|
|
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,
|
package/src/python/enums.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { Enum, EmitterContext, GeneratedFile
|
|
2
|
-
import { toUpperSnakeCase
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
}
|