@workos/oagen-emitters 0.7.2 → 0.7.4
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 +14 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-CGPujyaL.mjs → plugin-BoTAX4nl.mjs} +209 -65
- package/dist/plugin-BoTAX4nl.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +2 -2
- package/src/kotlin/path-expression.ts +51 -0
- package/src/kotlin/resources.ts +9 -20
- package/src/kotlin/wrappers.ts +3 -14
- package/src/node/path-expression.ts +37 -0
- package/src/node/resources.ts +2 -2
- package/src/node/wrappers.ts +2 -2
- package/src/php/path-expression.ts +52 -0
- package/src/php/resources.ts +4 -11
- package/src/php/wrappers.ts +3 -5
- package/src/python/path-expression.ts +52 -0
- package/src/python/resources.ts +17 -14
- package/src/python/wrappers.ts +2 -10
- package/src/shared/path-template.ts +64 -0
- package/test/php/resources.test.ts +1 -1
- package/test/python/resources.test.ts +2 -1
- package/test/shared/path-template.test.ts +70 -0
- package/dist/plugin-CGPujyaL.mjs.map +0 -1
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as workosEmittersPlugin } from "./plugin-
|
|
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.
|
|
3
|
+
"version": "0.7.4",
|
|
4
4
|
"description": "WorkOS' oagen emitters",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "WorkOS",
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
"node": ">=24.10.0"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@workos/oagen": "^0.
|
|
57
|
+
"@workos/oagen": "^0.15.0"
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -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
|
+
}
|
package/src/kotlin/resources.ts
CHANGED
|
@@ -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
|
|
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';
|
package/src/kotlin/wrappers.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { EmitterContext, ResolvedOperation, ResolvedWrapper
|
|
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 =
|
|
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
|
+
}
|
package/src/node/resources.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/node/wrappers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/php/resources.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
749
|
-
return `"${path}"`;
|
|
742
|
+
return buildPhpPathExpression(op.path, { valueAccessorParams: valueAccessor });
|
|
750
743
|
}
|
|
751
744
|
|
|
752
745
|
function isEnumType(ref: import('@workos/oagen').TypeRef): boolean {
|
package/src/php/wrappers.ts
CHANGED
|
@@ -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
|
-
|
|
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: ${
|
|
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
|
+
}
|
package/src/python/resources.ts
CHANGED
|
@@ -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
|
-
|
|
1525
|
-
const
|
|
1526
|
-
|
|
1527
|
-
|
|
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
|
|
1541
|
+
return buildPythonPathExpression(op.path, { enumParams });
|
|
1539
1542
|
}
|
package/src/python/wrappers.ts
CHANGED
|
@@ -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
|
-
|
|
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('
|
|
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(
|
|
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
|
+
});
|