@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,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
  }
@@ -12,14 +12,7 @@ import type {
12
12
 
13
13
  /** Extend Parameter with `explode` until @workos/oagen publishes the field. */
14
14
  type ParameterExt = Parameter & { explode?: boolean };
15
- import {
16
- planOperation,
17
- toPascalCase,
18
- toSnakeCase,
19
- collectModelRefs,
20
- collectEnumRefs,
21
- assignModelsToServices,
22
- } from '@workos/oagen';
15
+ import { planOperation, toPascalCase, toSnakeCase, collectModelRefs, collectEnumRefs } from '@workos/oagen';
23
16
  import { mapTypeRefUnquoted } from './type-map.js';
24
17
  import {
25
18
  className,
@@ -49,6 +42,7 @@ import {
49
42
  clientFieldExpression,
50
43
  } from './wrappers.js';
51
44
  import { buildPythonPathExpression } from './path-expression.js';
45
+ import { computeSchemaPlacement } from './shared-schemas.js';
52
46
 
53
47
  /**
54
48
  * Compute the Python parameter name for a body field, prefixing with `body_` if it
@@ -293,11 +287,20 @@ function emitMethodSignature(
293
287
  lines.push(' limit: Optional[int] = None,');
294
288
  lines.push(' before: Optional[str] = None,');
295
289
  lines.push(' after: Optional[str] = None,');
296
- // Use typed enum for order param if the spec provides one, otherwise fall back to str
290
+ // Use typed enum for order param if the spec provides one, otherwise fall
291
+ // back to str. The default value comes from the spec's `default:` field;
292
+ // when the spec drops the default, we surface that as `None` rather than
293
+ // silently restoring "desc" client-side (which would mask a server-side
294
+ // behavior change from the caller).
297
295
  const orderParam = op.queryParams.find((p) => p.name === 'order');
298
296
  const orderType =
299
297
  orderParam && orderParam.type.kind === 'enum' ? mapTypeRefUnquoted(orderParam.type, specEnumNames, true) : 'str';
300
- lines.push(` order: Optional[${orderType}] = "desc",`);
298
+ const orderDefaultRaw = orderParam?.default;
299
+ const orderDefault =
300
+ typeof orderDefaultRaw === 'string' || typeof orderDefaultRaw === 'number' || typeof orderDefaultRaw === 'boolean'
301
+ ? pythonLiteral(orderDefaultRaw)
302
+ : 'None';
303
+ lines.push(` order: Optional[${orderType}] = ${orderDefault},`);
301
304
  // Additional non-pagination query params
302
305
  for (const param of op.queryParams) {
303
306
  if (['limit', 'before', 'after', 'order'].includes(param.name)) continue;
@@ -1020,15 +1023,6 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1020
1023
  lines.push('');
1021
1024
  lines.push('from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Type, Union, cast');
1022
1025
 
1023
- // urllib.parse.quote is needed whenever any operation has a path parameter,
1024
- // so each interpolated id can be URL-encoded with safe="" before being
1025
- // concatenated into the request path.
1026
- const hasAnyPathParam =
1027
- allOperations.some((op) => op.pathParams.length > 0) || allOperations.some((op) => /\{[^{}]+\}/.test(op.path));
1028
- if (hasAnyPathParam) {
1029
- lines.push('from urllib.parse import quote');
1030
- }
1031
-
1032
1026
  lines.push('');
1033
1027
  lines.push('if TYPE_CHECKING:');
1034
1028
  lines.push(` from ${importPrefix}_client import AsyncWorkOSClient, WorkOSClient`);
@@ -1127,7 +1121,8 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1127
1121
  const actualModelImports = [...modelImports];
1128
1122
 
1129
1123
  // Split imports into same-service and cross-service (using mount-based dirs)
1130
- const modelToServiceMap = assignModelsToServices(ctx.spec.models, ctx.spec.services, ctx.modelHints);
1124
+ const placement = computeSchemaPlacement(ctx.spec, ctx);
1125
+ const modelToServiceMap = new Map(placement.modelToService);
1131
1126
  // Discriminator variant type aliases (e.g. EventSchemaVariant) live in the same
1132
1127
  // service as their dispatcher model, so ensure they resolve to the same directory.
1133
1128
  for (const model of ctx.spec.models) {
@@ -1167,30 +1162,10 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1167
1162
  }
1168
1163
  }
1169
1164
 
1170
- // Enum imports — same-service vs cross-service
1171
- const enumToServiceMap = new Map<string, string>();
1172
- for (const e of ctx.spec.enums) {
1173
- // Find which service uses this enum by walking full type trees
1174
- for (const svc of ctx.spec.services) {
1175
- for (const op of svc.operations) {
1176
- const refs = new Set<string>();
1177
- // Walk all type refs (including nested nullable/array/union) to find enums
1178
- const allTypeRefs = [
1179
- op.response,
1180
- ...(op.requestBody ? [op.requestBody] : []),
1181
- ...op.pathParams.map((p) => p.type),
1182
- ...op.queryParams.map((p) => p.type),
1183
- ...op.headerParams.map((p) => p.type),
1184
- ];
1185
- for (const typeRef of allTypeRefs) {
1186
- for (const ref of collectEnumRefs(typeRef)) refs.add(ref);
1187
- }
1188
- if (refs.has(e.name) && !enumToServiceMap.has(e.name)) {
1189
- enumToServiceMap.set(e.name, svc.name);
1190
- }
1191
- }
1192
- }
1193
- }
1165
+ // Enum imports — same-service vs cross-service. Shared enums (referenced
1166
+ // by 2+ services) are intentionally absent from this map so they resolve
1167
+ // to common/ via the resolveDir() fallback below.
1168
+ const enumToServiceMap = placement.enumToService;
1194
1169
 
1195
1170
  const localEnums: string[] = [];
1196
1171
  const crossServiceEnums = new Map<string, string[]>();