@workos/oagen-emitters 0.7.2 → 0.7.3

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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-CGPujyaL.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-BoTAX4nl.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -0,0 +1,51 @@
1
+ import { parsePathTemplate, hasPathParams } from '../shared/path-template.js';
2
+ import { ktLiteral, propertyName } from './naming.js';
3
+
4
+ /**
5
+ * The fully-qualified runtime helper that the generated code calls. Kotlin
6
+ * doesn't ship a path-segment URL encoder out of the box (java.net.URLEncoder
7
+ * is form-encoding — it encodes space as "+", which is wrong for path
8
+ * segments). The runtime helper lives in workos-kotlin at
9
+ * com.workos.common.http.encodePathSegment.
10
+ */
11
+ export const KOTLIN_PATH_ENCODE_IMPORT = 'com.workos.common.http.encodePathSegment';
12
+
13
+ export interface KotlinPathExpression {
14
+ /** The Kotlin expression to splice in as the `path = ...` argument. */
15
+ expression: string;
16
+ /** Whether the caller must add `KOTLIN_PATH_ENCODE_IMPORT` to its import set. */
17
+ requiresEncodeImport: boolean;
18
+ }
19
+
20
+ /**
21
+ * Build the Kotlin string-template that the SDK passes as the request path.
22
+ *
23
+ * Every {paramName} placeholder is wrapped in `encodePathSegment(...)` so a
24
+ * caller-supplied id containing "../" cannot be normalized by the underlying
25
+ * HTTP transport into a different endpoint of the WorkOS API while still
26
+ * authenticated with the application's API key.
27
+ *
28
+ * "/orgs" → `"orgs"`
29
+ * "/orgs/{id}" → `"orgs/${encodePathSegment(id)}"`
30
+ * "/orgs/{id}/foo" → `"orgs/${encodePathSegment(id)}/foo"`
31
+ */
32
+ export function buildKotlinPathExpression(rawPath: string): KotlinPathExpression {
33
+ const segments = parsePathTemplate(rawPath);
34
+ if (!hasPathParams(segments)) {
35
+ return { expression: ktLiteral(rawPath), requiresEncodeImport: false };
36
+ }
37
+
38
+ let body = '';
39
+ for (const seg of segments) {
40
+ if (seg.kind === 'literal') {
41
+ body += escapeKotlinStringLiteral(seg.value);
42
+ } else {
43
+ body += `\${encodePathSegment(${propertyName(seg.name)})}`;
44
+ }
45
+ }
46
+ return { expression: `"${body}"`, requiresEncodeImport: true };
47
+ }
48
+
49
+ function escapeKotlinStringLiteral(literal: string): string {
50
+ return literal.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$');
51
+ }
@@ -37,6 +37,7 @@ import {
37
37
  import { generateWrapperMethods } from './wrappers.js';
38
38
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
39
39
  import { isHandwrittenOverride } from './overrides.js';
40
+ import { buildKotlinPathExpression, KOTLIN_PATH_ENCODE_IMPORT } from './path-expression.js';
40
41
 
41
42
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
42
43
 
@@ -113,6 +114,11 @@ function generateApiClass(
113
114
  }
114
115
  // Wrapper methods use bodyOf() for request body construction.
115
116
  imports.add('com.workos.common.http.bodyOf');
117
+ // Wrappers share the operation's path; if it has any {param}, the
118
+ // wrapper emits encodePathSegment(...) and needs the import.
119
+ if (/\{[^{}]+\}/.test(resolvedOp!.operation.path)) {
120
+ imports.add(KOTLIN_PATH_ENCODE_IMPORT);
121
+ }
116
122
  const wrapperLines = generateWrapperMethods(resolvedOp!, ctx);
117
123
  if (body.length > 0) body.push('');
118
124
  for (const line of wrapperLines) body.push(line);
@@ -374,7 +380,9 @@ function renderMethod(
374
380
  (Object.keys(defaults).length > 0 || inferFromClient.length > 0) &&
375
381
  specDeclaresBody);
376
382
  const appendDefaultsAsQuery = !hasBody && (Object.keys(defaults).length > 0 || inferFromClient.length > 0);
377
- const pathExpr = buildPathExpression(op.path, pathParams);
383
+ const pathBuilt = buildKotlinPathExpression(op.path);
384
+ const pathExpr = pathBuilt.expression;
385
+ if (pathBuilt.requiresEncodeImport) imports.add(KOTLIN_PATH_ENCODE_IMPORT);
378
386
 
379
387
  if (
380
388
  op.path === '/user_management/authenticate' &&
@@ -727,25 +735,6 @@ function _emitBodyField(field: Field, kotlinParamName: string, isPatch: boolean)
727
735
  return [` if (${prop} != null) body[${ktLiteral(field.name)}] = ${prop}`];
728
736
  }
729
737
 
730
- function buildPathExpression(path: string, pathParams: Parameter[]): string {
731
- if (pathParams.length === 0) return ktLiteral(path);
732
- let result = path;
733
- for (const pp of pathParams) {
734
- const placeholder = `{${pp.name}}`;
735
- const propName = propertyName(pp.name);
736
- // Use $propName for simple identifiers and ${propName} only when followed by
737
- // an ident-continuing char (to avoid false continuations). ktlint prefers the
738
- // unbraced form for bare identifiers.
739
- const replacement = isBareIdentifier(propName) ? `\$${propName}` : `\${${propName}}`;
740
- result = result.replaceAll(placeholder, replacement);
741
- }
742
- return `"${result.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
743
- }
744
-
745
- function isBareIdentifier(name: string): boolean {
746
- return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
747
- }
748
-
749
738
  function pickNamedQueryParam(sorted: Parameter[], name: string): string {
750
739
  const match = sorted.find((p) => p.name === name);
751
740
  return match ? propertyName(match.name) : 'null';
@@ -1,8 +1,9 @@
1
- import type { EmitterContext, ResolvedOperation, ResolvedWrapper, Parameter } from '@workos/oagen';
1
+ import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
2
2
  import { className, propertyName, ktLiteral, clientFieldExpression, escapeReserved } from './naming.js';
3
3
  import { mapTypeRef, mapTypeRefOptional } from './type-map.js';
4
4
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
5
5
  import { sortPathParamsByTemplateOrder } from './resources.js';
6
+ import { buildKotlinPathExpression } from './path-expression.js';
6
7
 
7
8
  /**
8
9
  * Emit Kotlin wrapper methods for a union-split operation. Each wrapper
@@ -125,7 +126,7 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
125
126
  lines.push(` val body = linkedMapOf<String, Any?>()`);
126
127
  }
127
128
 
128
- const pathExpr = buildPathExpr(op.path, pathParams);
129
+ const pathExpr = buildKotlinPathExpression(op.path).expression;
129
130
  const httpMethod = op.httpMethod.toUpperCase();
130
131
 
131
132
  lines.push(` val config =`);
@@ -154,15 +155,3 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
154
155
  function escapeKdoc(s: string): string {
155
156
  return s.replace(/\*\//g, '*\u200b/');
156
157
  }
157
-
158
- function buildPathExpr(path: string, pathParams: Parameter[]): string {
159
- if (pathParams.length === 0) return ktLiteral(path);
160
- let result = path;
161
- for (const pp of pathParams) {
162
- const placeholder = `{${pp.name}}`;
163
- const propName = propertyName(pp.name);
164
- const replacement = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(propName) ? `\$${propName}` : `\${${propName}}`;
165
- result = result.replaceAll(placeholder, replacement);
166
- }
167
- return `"${result.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
168
- }
@@ -0,0 +1,37 @@
1
+ import { parsePathTemplate, hasPathParams, type PathSegment } from '../shared/path-template.js';
2
+ import { fieldName } from './naming.js';
3
+
4
+ /**
5
+ * Build the TypeScript expression that the SDK passes as the request path.
6
+ *
7
+ * Every {paramName} placeholder becomes `${encodeURIComponent(name)}` inside a
8
+ * template literal. encodeURIComponent is used (not encodeURI) because we want
9
+ * "/" to be encoded too — otherwise a caller-supplied id containing "../" can
10
+ * be normalized by the underlying HTTP transport (libcurl, fetch, etc.) into
11
+ * a different endpoint of the WorkOS API while still authenticated with the
12
+ * application's API key.
13
+ *
14
+ * "/orgs" → `'orgs'`
15
+ * "/orgs/{id}" → `` `orgs/${encodeURIComponent(id)}` ``
16
+ * "/orgs/{id}/foo" → `` `orgs/${encodeURIComponent(id)}/foo` ``
17
+ */
18
+ export function buildNodePathExpression(rawPath: string): string {
19
+ const segments = parsePathTemplate(rawPath);
20
+ if (!hasPathParams(segments)) {
21
+ return `'${rawPath}'`;
22
+ }
23
+
24
+ let body = '';
25
+ for (const seg of segments) {
26
+ body += renderSegment(seg);
27
+ }
28
+ return `\`${body}\``;
29
+ }
30
+
31
+ function renderSegment(seg: PathSegment): string {
32
+ if (seg.kind === 'literal') {
33
+ // Template-literal-safe escapes: backtick, backslash, ${
34
+ return seg.value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
35
+ }
36
+ return `\${encodeURIComponent(${fieldName(seg.name)})}`;
37
+ }
@@ -40,6 +40,7 @@ import {
40
40
  getOpInferFromClient,
41
41
  } from '../shared/resolved-ops.js';
42
42
  import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
43
+ import { buildNodePathExpression } from './path-expression.js';
43
44
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
44
45
 
45
46
  /**
@@ -1417,8 +1418,7 @@ function renderQueryExpr(queryParams: { name: string; required: boolean }[]): st
1417
1418
  }
1418
1419
 
1419
1420
  function buildPathStr(op: Operation): string {
1420
- const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
1421
- return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
1421
+ return buildNodePathExpression(op.path);
1422
1422
  }
1423
1423
 
1424
1424
  function buildPathParams(op: Operation, specEnumNames?: Set<string>): string {
@@ -3,6 +3,7 @@ import { toCamelCase } from '@workos/oagen';
3
3
  import { fieldName, resolveInterfaceName, wireInterfaceName } from './naming.js';
4
4
  import { mapTypeRef } from './type-map.js';
5
5
  import { resolveWrapperParams, formatWrapperDescription } from '../shared/wrapper-utils.js';
6
+ import { buildNodePathExpression } from './path-expression.js';
6
7
 
7
8
  /**
8
9
  * Generate TypeScript wrapper method lines for union split operations.
@@ -157,8 +158,7 @@ function emitWrapperMethod(
157
158
 
158
159
  /** Build a path template string from an Operation. */
159
160
  function buildPathStr(op: { path: string; pathParams: Array<{ name: string }> }): string {
160
- const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
161
- return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
161
+ return buildNodePathExpression(op.path);
162
162
  }
163
163
 
164
164
  /** Convert a JS value to a TypeScript literal. */
@@ -0,0 +1,52 @@
1
+ import { parsePathTemplate, hasPathParams } from '../shared/path-template.js';
2
+ import { fieldName } from './naming.js';
3
+
4
+ export interface PhpPathOptions {
5
+ /**
6
+ * Raw OpenAPI param names whose runtime value is enum- or model-valued and
7
+ * therefore needs `->value` accessed before encoding. Mirrors the legacy
8
+ * resources.ts behavior that treated `kind === 'enum'` and `kind === 'model'`
9
+ * the same way.
10
+ */
11
+ valueAccessorParams?: ReadonlySet<string>;
12
+ }
13
+
14
+ /**
15
+ * Build the PHP expression that the SDK passes to HttpClient as `path:`.
16
+ *
17
+ * Concatenation, not interpolation: PHP does not allow function calls inside
18
+ * "..." strings, and every parameter must be wrapped in `rawurlencode(...)` so
19
+ * that an unencoded "../" in a caller-supplied id cannot be normalized by
20
+ * libcurl into a different endpoint of the WorkOS API while still
21
+ * authenticated with the application's API key.
22
+ *
23
+ * "/orgs" → `'orgs'`
24
+ * "/orgs/{id}" → `'orgs/' . rawurlencode($id)`
25
+ * "/orgs/{id}/users" → `'orgs/' . rawurlencode($id) . '/users'`
26
+ * "/orgs/{id}" with id ∈ valueAccessorParams → `'orgs/' . rawurlencode($id->value)`
27
+ */
28
+ export function buildPhpPathExpression(rawPath: string, options: PhpPathOptions = {}): string {
29
+ const segments = parsePathTemplate(rawPath, { stripLeadingSlash: true });
30
+ if (segments.length === 0) return "''";
31
+
32
+ if (!hasPathParams(segments)) {
33
+ return phpSingleQuoted((segments[0] as { value: string }).value);
34
+ }
35
+
36
+ const valueAccessor = options.valueAccessorParams;
37
+ const parts: string[] = [];
38
+ for (const seg of segments) {
39
+ if (seg.kind === 'literal') {
40
+ parts.push(phpSingleQuoted(seg.value));
41
+ } else {
42
+ const varName = fieldName(seg.name);
43
+ const accessor = valueAccessor?.has(seg.name) ? `$${varName}->value` : `$${varName}`;
44
+ parts.push(`rawurlencode(${accessor})`);
45
+ }
46
+ }
47
+ return parts.join(' . ');
48
+ }
49
+
50
+ function phpSingleQuoted(literal: string): string {
51
+ return `'${literal.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
52
+ }
@@ -14,6 +14,7 @@ import {
14
14
  } from '../shared/resolved-ops.js';
15
15
  import { generateWrapperMethods } from './wrappers.js';
16
16
  import { phpDocComment } from './utils.js';
17
+ import { buildPhpPathExpression } from './path-expression.js';
17
18
 
18
19
  /**
19
20
  * Resolve the resource class name for a service (used by client.ts).
@@ -734,19 +735,11 @@ function buildBodyParamMap(op: Operation, bodyModel: Model | null): Map<string,
734
735
  }
735
736
 
736
737
  function buildPathString(op: Operation): string {
737
- let path = op.path.startsWith('/') ? op.path.slice(1) : op.path;
738
- if (op.pathParams.length === 0) {
739
- return `'${path}'`;
740
- }
741
- // Build a map of param name → PHP expression (with ->value for enum types)
742
- const paramExprs = new Map<string, string>();
738
+ const valueAccessor = new Set<string>();
743
739
  for (const p of op.pathParams) {
744
- const phpName = fieldName(p.name);
745
- const isEnum = p.type.kind === 'enum' || p.type.kind === 'model';
746
- paramExprs.set(p.name, isEnum ? `{$${phpName}->value}` : `{$${phpName}}`);
740
+ if (p.type.kind === 'enum' || p.type.kind === 'model') valueAccessor.add(p.name);
747
741
  }
748
- path = path.replace(/\{([^}]+)\}/g, (_match, param) => paramExprs.get(param) ?? `{$${fieldName(param)}}`);
749
- return `"${path}"`;
742
+ return buildPhpPathExpression(op.path, { valueAccessorParams: valueAccessor });
750
743
  }
751
744
 
752
745
  function isEnumType(ref: import('@workos/oagen').TypeRef): boolean {
@@ -4,6 +4,7 @@ import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
4
4
  import { className, fieldName } from './naming.js';
5
5
  import { phpDocComment } from './utils.js';
6
6
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
7
+ import { buildPhpPathExpression } from './path-expression.js';
7
8
 
8
9
  /**
9
10
  * Generate PHP wrapper methods for split union operations.
@@ -108,15 +109,12 @@ function emitWrapperMethod(
108
109
 
109
110
  // Delegate to HTTP client
110
111
  const httpMethod = op.httpMethod.toUpperCase();
111
- let path = op.path.startsWith('/') ? op.path.slice(1) : op.path;
112
- const hasInterpolation = /\{[^}]+\}/.test(path);
113
- path = path.replace(/\{([^}]+)\}/g, (_match, param) => `{$${fieldName(param)}}`);
114
- const pathQuote = hasInterpolation ? '"' : "'";
112
+ const pathExpr = buildPhpPathExpression(op.path);
115
113
 
116
114
  lines.push('');
117
115
  lines.push(' $response = $this->client->request(');
118
116
  lines.push(` method: '${httpMethod}',`);
119
- lines.push(` path: ${pathQuote}${path}${pathQuote},`);
117
+ lines.push(` path: ${pathExpr},`);
120
118
  lines.push(' body: $body,');
121
119
  lines.push(' options: $options,');
122
120
  lines.push(' );');
@@ -0,0 +1,52 @@
1
+ import { parsePathTemplate, hasPathParams } from '../shared/path-template.js';
2
+ import { fieldName } from './naming.js';
3
+
4
+ export interface PythonPathOptions {
5
+ /** Raw OpenAPI param names whose runtime value is enum-typed. */
6
+ enumParams?: ReadonlySet<string>;
7
+ }
8
+
9
+ /**
10
+ * Build the Python f-string that the SDK passes to the request layer.
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.
19
+ *
20
+ * Generated files using this helper must import `quote` (e.g.
21
+ * `from urllib.parse import quote`).
22
+ *
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='')}"`
26
+ */
27
+ export function buildPythonPathExpression(rawPath: string, options: PythonPathOptions = {}): string {
28
+ 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
+ }
34
+
35
+ const enums = options.enumParams;
36
+ let body = '';
37
+ for (const seg of segments) {
38
+ if (seg.kind === 'literal') {
39
+ body += escapePyDoubleQuoted(seg.value);
40
+ } else {
41
+ const varName = fieldName(seg.name);
42
+ const inner = enums?.has(seg.name) ? `enum_value(${varName})` : varName;
43
+ body += `{quote(str(${inner}), safe='')}`;
44
+ }
45
+ }
46
+ return `f"${body}"`;
47
+ }
48
+
49
+ function escapePyDoubleQuoted(literal: string): string {
50
+ // f-strings: backslash, double-quote, and "{"/"}" all need escaping
51
+ return literal.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\{/g, '{{').replace(/\}/g, '}}');
52
+ }
@@ -48,6 +48,7 @@ import {
48
48
  pythonLiteral,
49
49
  clientFieldExpression,
50
50
  } from './wrappers.js';
51
+ import { buildPythonPathExpression } from './path-expression.js';
51
52
 
52
53
  /**
53
54
  * Compute the Python parameter name for a body field, prefixing with `body_` if it
@@ -1018,6 +1019,16 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1018
1019
  lines.push('from __future__ import annotations');
1019
1020
  lines.push('');
1020
1021
  lines.push('from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Type, Union, cast');
1022
+
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
+
1021
1032
  lines.push('');
1022
1033
  lines.push('if TYPE_CHECKING:');
1023
1034
  lines.push(` from ${importPrefix}_client import AsyncWorkOSClient, WorkOSClient`);
@@ -1518,22 +1529,14 @@ function buildErrorRaisesBlock(op: Operation): string[] {
1518
1529
 
1519
1530
  /**
1520
1531
  * Build a Python f-string path expression from an operation path.
1521
- * E.g., "/organizations/{id}" -> f"organizations/{id}"
1532
+ * E.g., "/organizations/{id}" -> f"organizations/{quote(str(id), safe='')}"
1522
1533
  */
1523
1534
  function buildPathString(op: Operation): string {
1524
- // Strip leading slash and convert {param} to Python f-string interpolation
1525
- const path = op.path.replace(/^\//, '');
1526
- if (op.pathParams.length === 0) {
1527
- return `"${path}"`;
1528
- }
1529
- // Convert {paramName} to {fieldName(paramName)}
1530
- let fPath = path;
1531
- for (const param of op.pathParams) {
1532
- if (param.type.kind === 'enum' || (param.type.kind === 'nullable' && (param.type as any).inner?.kind === 'enum')) {
1533
- fPath = fPath.replace(`{${param.name}}`, `{enum_value(${fieldName(param.name)})}`);
1534
- } else {
1535
- fPath = fPath.replace(`{${param.name}}`, `{${fieldName(param.name)}}`);
1535
+ const enumParams = new Set<string>();
1536
+ for (const p of op.pathParams) {
1537
+ if (p.type.kind === 'enum' || (p.type.kind === 'nullable' && (p.type as any).inner?.kind === 'enum')) {
1538
+ enumParams.add(p.name);
1536
1539
  }
1537
1540
  }
1538
- return `f"${fPath}"`;
1541
+ return buildPythonPathExpression(op.path, { enumParams });
1539
1542
  }
@@ -2,6 +2,7 @@ import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos
2
2
  import { toSnakeCase } from '@workos/oagen';
3
3
  import { className, fieldName } from './naming.js';
4
4
  import { resolveWrapperParams, formatWrapperDescription } from '../shared/wrapper-utils.js';
5
+ import { buildPythonPathExpression } from './path-expression.js';
5
6
 
6
7
  /**
7
8
  * Generate Python wrapper method lines for split operations.
@@ -115,16 +116,7 @@ function emitWrapperMethod(
115
116
  }
116
117
 
117
118
  // Build path expression
118
- let pathExpr: string;
119
- if (op.pathParams.length > 0) {
120
- let path = op.path.replace(/^\//, '');
121
- for (const p of op.pathParams) {
122
- path = path.replace(`{${p.name}}`, `{${fieldName(p.name)}}`);
123
- }
124
- pathExpr = `f"${path}"`;
125
- } else {
126
- pathExpr = `"${op.path.replace(/^\//, '')}"`;
127
- }
119
+ const pathExpr = buildPythonPathExpression(op.path);
128
120
 
129
121
  // Make the request
130
122
  const awaitPrefix = isAsync ? 'await ' : '';
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Helpers for parsing OpenAPI path templates into structured segments that
3
+ * each language emitter can render with proper per-segment URL encoding.
4
+ *
5
+ * SECURITY CONTEXT: every emitted path-parameter interpolation must wrap the
6
+ * runtime value in a URL-encoding call (e.g. `rawurlencode` in PHP,
7
+ * `encodeURIComponent` in TypeScript, `urllib.parse.quote(..., safe="")` in
8
+ * Python, a path-segment helper in Kotlin). Without per-segment encoding, a
9
+ * caller-supplied id containing "../" is silently normalized by libcurl
10
+ * (RFC 3986 dot-segment removal) before transmission, so the request reaches a
11
+ * different endpoint of the WorkOS API while still authenticated with the
12
+ * application's bearer token — i.e. forged cross-resource API requests under
13
+ * the application's API key. See workos-php HttpClient hardening for the
14
+ * defense-in-depth runtime guard; this module is the structural fix.
15
+ */
16
+
17
+ export type PathSegment = { kind: 'literal'; value: string } | { kind: 'param'; name: string };
18
+
19
+ export interface ParsePathOptions {
20
+ /** When true, drop a single leading "/" before parsing. */
21
+ stripLeadingSlash?: boolean;
22
+ }
23
+
24
+ /**
25
+ * Parse an OpenAPI path template into an ordered list of literal and
26
+ * parameter segments. Adjacent literals are coalesced into a single segment
27
+ * (the only way two literals can be adjacent is if the input had `{}{}`
28
+ * which is malformed; we still handle it deterministically).
29
+ *
30
+ * Examples:
31
+ * "/orgs/{id}/users/{uid}" → [
32
+ * { kind: 'literal', value: '/orgs/' },
33
+ * { kind: 'param', name: 'id' },
34
+ * { kind: 'literal', value: '/users/' },
35
+ * { kind: 'param', name: 'uid' },
36
+ * ]
37
+ * "/health" → [{ kind: 'literal', value: '/health' }]
38
+ * "" → []
39
+ */
40
+ export function parsePathTemplate(path: string, options: ParsePathOptions = {}): PathSegment[] {
41
+ const normalized = options.stripLeadingSlash && path.startsWith('/') ? path.slice(1) : path;
42
+ if (normalized === '') return [];
43
+
44
+ const segments: PathSegment[] = [];
45
+ const re = /\{([^{}]+)\}/g;
46
+ let cursor = 0;
47
+ let m: RegExpExecArray | null;
48
+ while ((m = re.exec(normalized)) !== null) {
49
+ if (m.index > cursor) {
50
+ segments.push({ kind: 'literal', value: normalized.slice(cursor, m.index) });
51
+ }
52
+ segments.push({ kind: 'param', name: m[1] });
53
+ cursor = m.index + m[0].length;
54
+ }
55
+ if (cursor < normalized.length) {
56
+ segments.push({ kind: 'literal', value: normalized.slice(cursor) });
57
+ }
58
+ return segments;
59
+ }
60
+
61
+ /** True when at least one segment is a parameter placeholder. */
62
+ export function hasPathParams(segments: PathSegment[]): boolean {
63
+ return segments.some((s) => s.kind === 'param');
64
+ }
@@ -118,7 +118,7 @@ describe('generateResources', () => {
118
118
 
119
119
  expect(result[0].content).toContain('public function getOrganization(');
120
120
  expect(result[0].content).toContain('string $id');
121
- expect(result[0].content).toContain('"organizations/{$id}"');
121
+ expect(result[0].content).toContain("'organizations/' . rawurlencode($id)");
122
122
  expect(result[0].content).toContain('Organization::fromArray($response)');
123
123
  });
124
124
 
@@ -83,7 +83,8 @@ describe('generateResources', () => {
83
83
  // GET method with path param
84
84
  expect(content).toContain('def get_organization(');
85
85
  expect(content).toContain('id: str,');
86
- expect(content).toContain('f"organizations/{id}"');
86
+ expect(content).toContain(`f"organizations/{quote(str(id), safe='')}"`);
87
+ expect(content).toContain('from urllib.parse import quote');
87
88
  expect(content).toContain('model=Organization');
88
89
  // Public request methods (no underscore prefix)
89
90
  expect(content).toContain('self._client.request(');
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parsePathTemplate, hasPathParams } from '../../src/shared/path-template.js';
3
+
4
+ describe('parsePathTemplate', () => {
5
+ it('splits a path with a single param', () => {
6
+ expect(parsePathTemplate('/orgs/{id}')).toEqual([
7
+ { kind: 'literal', value: '/orgs/' },
8
+ { kind: 'param', name: 'id' },
9
+ ]);
10
+ });
11
+
12
+ it('splits a path with multiple params', () => {
13
+ expect(parsePathTemplate('/orgs/{id}/users/{uid}')).toEqual([
14
+ { kind: 'literal', value: '/orgs/' },
15
+ { kind: 'param', name: 'id' },
16
+ { kind: 'literal', value: '/users/' },
17
+ { kind: 'param', name: 'uid' },
18
+ ]);
19
+ });
20
+
21
+ it('handles a path that ends in a param', () => {
22
+ expect(parsePathTemplate('/foo/{id}')).toEqual([
23
+ { kind: 'literal', value: '/foo/' },
24
+ { kind: 'param', name: 'id' },
25
+ ]);
26
+ });
27
+
28
+ it('handles a path that starts with a param', () => {
29
+ expect(parsePathTemplate('{id}/foo')).toEqual([
30
+ { kind: 'param', name: 'id' },
31
+ { kind: 'literal', value: '/foo' },
32
+ ]);
33
+ });
34
+
35
+ it('returns a single literal segment for paths with no params', () => {
36
+ expect(parsePathTemplate('/health')).toEqual([{ kind: 'literal', value: '/health' }]);
37
+ });
38
+
39
+ it('returns empty for an empty string', () => {
40
+ expect(parsePathTemplate('')).toEqual([]);
41
+ });
42
+
43
+ it('strips a single leading slash when requested', () => {
44
+ expect(parsePathTemplate('/orgs/{id}', { stripLeadingSlash: true })).toEqual([
45
+ { kind: 'literal', value: 'orgs/' },
46
+ { kind: 'param', name: 'id' },
47
+ ]);
48
+ });
49
+
50
+ it('preserves snake_case param names verbatim', () => {
51
+ expect(parsePathTemplate('/audit_logs/exports/{audit_log_export_id}')).toEqual([
52
+ { kind: 'literal', value: '/audit_logs/exports/' },
53
+ { kind: 'param', name: 'audit_log_export_id' },
54
+ ]);
55
+ });
56
+ });
57
+
58
+ describe('hasPathParams', () => {
59
+ it('returns true when any segment is a param', () => {
60
+ expect(hasPathParams(parsePathTemplate('/orgs/{id}'))).toBe(true);
61
+ });
62
+
63
+ it('returns false for paths with no params', () => {
64
+ expect(hasPathParams(parsePathTemplate('/health'))).toBe(false);
65
+ });
66
+
67
+ it('returns false for empty paths', () => {
68
+ expect(hasPathParams(parsePathTemplate(''))).toBe(false);
69
+ });
70
+ });