@workos/oagen-emitters 0.11.0 → 0.12.1
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 +19 -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-DW3cnedr.mjs → plugin-CmfzawTp.mjs} +2851 -524
- package/dist/plugin-CmfzawTp.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 +9 -9
- package/src/index.ts +1 -0
- package/src/plugin.ts +2 -1
- package/src/python/path-expression.ts +75 -26
- package/src/python/resources.ts +0 -9
- package/src/rust/client.ts +62 -0
- package/src/rust/enums.ts +201 -0
- package/src/rust/fixtures.ts +196 -0
- package/src/rust/index.ts +95 -0
- package/src/rust/manifest.ts +31 -0
- package/src/rust/models.ts +165 -0
- package/src/rust/naming.ts +131 -0
- package/src/rust/resources.ts +1324 -0
- package/src/rust/secret.ts +59 -0
- package/src/rust/tests.ts +818 -0
- package/src/rust/type-map.ts +225 -0
- package/test/entrypoint.test.ts +1 -0
- package/test/plugin.test.ts +2 -1
- package/test/python/resources.test.ts +2 -2
- package/test/rust/client.test.ts +62 -0
- package/test/rust/enums.test.ts +117 -0
- package/test/rust/fixtures.test.ts +227 -0
- package/test/rust/manifest.test.ts +73 -0
- package/test/rust/models.test.ts +177 -0
- package/test/rust/resources.test.ts +748 -0
- package/test/rust/tests.test.ts +504 -0
- package/test/rust/type-map.test.ts +83 -0
- package/dist/plugin-DW3cnedr.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,IAAI,CAAC,WAAA"}
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as workosEmittersPlugin } from "./plugin-
|
|
1
|
+
import { t as workosEmittersPlugin } from "./plugin-CmfzawTp.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.1",
|
|
4
4
|
"description": "WorkOS' oagen emitters",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "WorkOS",
|
|
@@ -38,22 +38,22 @@
|
|
|
38
38
|
"prepare": "husky"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@commitlint/cli": "^
|
|
42
|
-
"@commitlint/config-conventional": "^
|
|
43
|
-
"@types/node": "^25.
|
|
41
|
+
"@commitlint/cli": "^21.0.1",
|
|
42
|
+
"@commitlint/config-conventional": "^21.0.1",
|
|
43
|
+
"@types/node": "^25.7.0",
|
|
44
44
|
"husky": "^9.1.7",
|
|
45
|
-
"oxfmt": "^0.
|
|
46
|
-
"oxlint": "^1.
|
|
45
|
+
"oxfmt": "^0.49.0",
|
|
46
|
+
"oxlint": "^1.64.0",
|
|
47
47
|
"prettier": "^3.8.3",
|
|
48
|
-
"tsdown": "^0.
|
|
48
|
+
"tsdown": "^0.22.0",
|
|
49
49
|
"tsx": "^4.21.0",
|
|
50
50
|
"typescript": "^6.0.3",
|
|
51
|
-
"vitest": "^4.1.
|
|
51
|
+
"vitest": "^4.1.6"
|
|
52
52
|
},
|
|
53
53
|
"engines": {
|
|
54
54
|
"node": ">=24.10.0"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@workos/oagen": "^0.18.
|
|
57
|
+
"@workos/oagen": "^0.18.2"
|
|
58
58
|
}
|
|
59
59
|
}
|
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,
|
|
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
|
|
10
|
+
* Build the Python tuple expression that the SDK passes to the request layer.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
21
|
-
* `
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/python/resources.ts
CHANGED
|
@@ -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
|
+
}
|