@workos/oagen-emitters 0.11.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.
@@ -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-DW3cnedr.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.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
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/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,4 +1,4 @@
1
- import { parsePathTemplate, hasPathParams } from '../shared/path-template.js';
1
+ import { parsePathTemplate, type PathSegment } from '../shared/path-template.js';
2
2
  import { fieldName } from './naming.js';
3
3
 
4
4
  export interface PythonPathOptions {
@@ -7,46 +7,95 @@ export interface PythonPathOptions {
7
7
  }
8
8
 
9
9
  /**
10
- * Build the Python f-string that the SDK passes to the request layer.
10
+ * Build the Python tuple expression that the SDK passes to the request layer.
11
11
  *
12
- * Every {paramName} placeholder is wrapped in
13
- * `urllib.parse.quote(str(...), safe="")` so that an unencoded "/" or "../"
14
- * in a caller-supplied id cannot be normalized by the underlying HTTP
15
- * transport into a different endpoint of the WorkOS API while still
16
- * authenticated with the application's API key. `safe=""` is critical:
17
- * the stdlib default of `safe="/"` does NOT encode "/" and would leave the
18
- * traversal vector open.
12
+ * The returned expression is a tuple of per-segment values. The request layer
13
+ * (`_BaseWorkOSClient._encode_path`) URL-encodes each element with `safe=""`
14
+ * before joining with "/", so a caller-supplied id containing "/" or "../"
15
+ * cannot escape its intended segment. This is the structural fix that lets
16
+ * the request layer make a real guarantee instead of inspecting an already-
17
+ * concatenated path string.
19
18
  *
20
- * Generated files using this helper must import `quote` (e.g.
21
- * `from urllib.parse import quote`).
19
+ * "/orgs" → `("orgs",)`
20
+ * "/orgs/{id}" → `("orgs", str(id))`
21
+ * "/orgs/{id}/users/{uid}" → `("orgs", str(id), "users", str(uid))`
22
+ * "/orgs/{id}" with id ∈ enums → `("orgs", str(enum_value(id)))`
22
23
  *
23
- * "/orgs" → `"orgs"`
24
- * "/orgs/{id}" → `f"orgs/{quote(str(id), safe='')}"`
25
- * "/orgs/{id}" with id enums → `f"orgs/{quote(str(enum_value(id)), safe='')}"`
24
+ * Mixed segments (e.g. literal text adjacent to a placeholder within a single
25
+ * path component) are emitted as a Python f-string element. Per-segment
26
+ * encoding is still applied to the whole element by the request layer; this
27
+ * is rare in WorkOS specs but is handled deterministically.
26
28
  */
27
29
  export function buildPythonPathExpression(rawPath: string, options: PythonPathOptions = {}): string {
28
30
  const segments = parsePathTemplate(rawPath, { stripLeadingSlash: true });
29
- if (segments.length === 0) return '""';
30
- if (!hasPathParams(segments)) {
31
- const literal = (segments[0] as { value: string }).value;
32
- return `"${escapePyDoubleQuoted(literal)}"`;
33
- }
31
+ if (segments.length === 0) return '()';
34
32
 
35
- const enums = options.enumParams;
36
- let body = '';
33
+ const components = splitIntoComponents(segments);
34
+ const parts = components.map((c) => emitComponent(c, options.enumParams));
35
+ return parts.length === 1 ? `(${parts[0]!},)` : `(${parts.join(', ')})`;
36
+ }
37
+
38
+ type Subpiece = { kind: 'literal'; value: string } | { kind: 'param'; name: string };
39
+
40
+ /**
41
+ * Split a parsed path template into one component per "/"-separated piece.
42
+ * Each component is a list of literal / param subpieces; multi-subpiece
43
+ * components occur only for mixed segments like `foo{id}bar`.
44
+ */
45
+ function splitIntoComponents(segments: PathSegment[]): Subpiece[][] {
46
+ const components: Subpiece[][] = [[]];
37
47
  for (const seg of segments) {
38
48
  if (seg.kind === 'literal') {
39
- body += escapePyDoubleQuoted(seg.value);
49
+ const parts = seg.value.split('/');
50
+ const first = parts[0];
51
+ if (first !== undefined && first !== '') {
52
+ components[components.length - 1]!.push({ kind: 'literal', value: first });
53
+ }
54
+ for (let i = 1; i < parts.length; i++) {
55
+ components.push([]);
56
+ const part = parts[i];
57
+ if (part !== undefined && part !== '') {
58
+ components[components.length - 1]!.push({ kind: 'literal', value: part });
59
+ }
60
+ }
40
61
  } else {
41
- const varName = fieldName(seg.name);
42
- const inner = enums?.has(seg.name) ? `enum_value(${varName})` : varName;
43
- body += `{quote(str(${inner}), safe='')}`;
62
+ components[components.length - 1]!.push({ kind: 'param', name: seg.name });
63
+ }
64
+ }
65
+ // Drop a trailing empty component if the path ended with a separator.
66
+ while (components.length > 1 && components[components.length - 1]!.length === 0) {
67
+ components.pop();
68
+ }
69
+ return components;
70
+ }
71
+
72
+ function emitComponent(component: Subpiece[], enumParams?: ReadonlySet<string>): string {
73
+ if (component.length === 1) {
74
+ const only = component[0]!;
75
+ if (only.kind === 'literal') return `"${escapePyDoubleQuoted(only.value)}"`;
76
+ const varName = fieldName(only.name);
77
+ const inner = enumParams?.has(only.name) ? `enum_value(${varName})` : varName;
78
+ return `str(${inner})`;
79
+ }
80
+ // Mixed component — fall back to an f-string. The request layer still
81
+ // URL-encodes the resulting element as a single segment.
82
+ let body = '';
83
+ for (const piece of component) {
84
+ if (piece.kind === 'literal') {
85
+ body += escapeFStringLiteral(piece.value);
86
+ } else {
87
+ const varName = fieldName(piece.name);
88
+ const inner = enumParams?.has(piece.name) ? `enum_value(${varName})` : varName;
89
+ body += `{${inner}}`;
44
90
  }
45
91
  }
46
92
  return `f"${body}"`;
47
93
  }
48
94
 
49
95
  function escapePyDoubleQuoted(literal: string): string {
50
- // f-strings: backslash, double-quote, and "{"/"}" all need escaping
96
+ return literal.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
97
+ }
98
+
99
+ function escapeFStringLiteral(literal: string): string {
51
100
  return literal.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\{/g, '{{').replace(/\}/g, '}}');
52
101
  }
@@ -1023,15 +1023,6 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1023
1023
  lines.push('');
1024
1024
  lines.push('from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Type, Union, cast');
1025
1025
 
1026
- // urllib.parse.quote is needed whenever any operation has a path parameter,
1027
- // so each interpolated id can be URL-encoded with safe="" before being
1028
- // concatenated into the request path.
1029
- const hasAnyPathParam =
1030
- allOperations.some((op) => op.pathParams.length > 0) || allOperations.some((op) => /\{[^{}]+\}/.test(op.path));
1031
- if (hasAnyPathParam) {
1032
- lines.push('from urllib.parse import quote');
1033
- }
1034
-
1035
1026
  lines.push('');
1036
1027
  lines.push('if TYPE_CHECKING:');
1037
1028
  lines.push(` from ${importPrefix}_client import AsyncWorkOSClient, WorkOSClient`);
@@ -0,0 +1,62 @@
1
+ import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import type { UnionRegistry } from './type-map.js';
3
+ import { moduleName } from './naming.js';
4
+ import { groupByMount } from '../shared/resolved-ops.js';
5
+ import { mountStructName } from './resources.js';
6
+
7
+ /**
8
+ * The Rust emitter only generates spec-derived endpoint logic. The HTTP
9
+ * client (`src/client.rs`), crate root (`src/lib.rs`), error types
10
+ * (`src/error.rs`), pagination helpers (`src/pagination.rs`), and
11
+ * `Cargo.toml` are hand-maintained in the live SDK — analogous to a
12
+ * `Gemfile` in Ruby.
13
+ *
14
+ * This pass runs last (after `generateModels` and `generateResources`) so
15
+ * the shared {@link UnionRegistry} has collected every synthesised oneOf
16
+ * union before being rendered into `src/models/_unions.rs`.
17
+ *
18
+ * It also emits `src/resources_api.rs`, an auxiliary `impl Client { ... }`
19
+ * block that exposes one accessor method per mount target. Keeping the
20
+ * accessors in a generated file lets `src/client.rs` remain hand-maintained
21
+ * without drifting as services mount/unmount.
22
+ */
23
+ export function generateClient(_spec: ApiSpec, ctx: EmitterContext, registry: UnionRegistry): GeneratedFile[] {
24
+ const files: GeneratedFile[] = [];
25
+
26
+ // _unions.rs — emitted unconditionally so the models barrel reference
27
+ // keeps the same shape across runs.
28
+ const unionsContent = registry.size() > 0 ? registry.render() : '// No oneOf-style unions registered.\n';
29
+ files.push({ path: 'src/models/_unions.rs', content: unionsContent, overwriteExisting: true });
30
+
31
+ // resources_api.rs — `impl Client { fn user_management() -> ... }`.
32
+ files.push({ path: 'src/resources_api.rs', content: renderResourcesApi(ctx), overwriteExisting: true });
33
+
34
+ return files;
35
+ }
36
+
37
+ function renderResourcesApi(ctx: EmitterContext): string {
38
+ const groups = groupByMount(ctx);
39
+ const targets: { accessor: string; struct: string }[] = [];
40
+ for (const [mountName, group] of groups) {
41
+ if (group.operations.length === 0) continue;
42
+ targets.push({ accessor: moduleName(mountName), struct: mountStructName(mountName) });
43
+ }
44
+ targets.sort((a, b) => a.accessor.localeCompare(b.accessor));
45
+
46
+ const lines: string[] = [];
47
+ lines.push('use crate::client::Client;');
48
+ for (const { struct } of targets) {
49
+ lines.push(`use crate::resources::${struct};`);
50
+ }
51
+ lines.push('');
52
+ lines.push('impl Client {');
53
+ targets.forEach(({ accessor, struct }, i) => {
54
+ lines.push(` /// Access the \`${accessor}\` resource.`);
55
+ lines.push(` pub fn ${accessor}(&self) -> ${struct}<'_> {`);
56
+ lines.push(` ${struct} { client: self }`);
57
+ lines.push(' }');
58
+ if (i < targets.length - 1) lines.push('');
59
+ });
60
+ lines.push('}');
61
+ return lines.join('\n') + '\n';
62
+ }