Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/kotlin/path-expression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { parsePathTemplate, hasPathParams } from '../shared/path-template.js';
import { ktLiteral, propertyName } from './naming.js';

/**
* The fully-qualified runtime helper that the generated code calls. Kotlin
* doesn't ship a path-segment URL encoder out of the box (java.net.URLEncoder
* is form-encoding — it encodes space as "+", which is wrong for path
* segments). The runtime helper lives in workos-kotlin at
* com.workos.common.http.encodePathSegment.
*/
export const KOTLIN_PATH_ENCODE_IMPORT = 'com.workos.common.http.encodePathSegment';

export interface KotlinPathExpression {
/** The Kotlin expression to splice in as the `path = ...` argument. */
expression: string;
/** Whether the caller must add `KOTLIN_PATH_ENCODE_IMPORT` to its import set. */
requiresEncodeImport: boolean;
}

/**
* Build the Kotlin string-template that the SDK passes as the request path.
*
* Every {paramName} placeholder is wrapped in `encodePathSegment(...)` so a
* caller-supplied id containing "../" cannot be normalized by the underlying
* HTTP transport into a different endpoint of the WorkOS API while still
* authenticated with the application's API key.
*
* "/orgs" → `"orgs"`
* "/orgs/{id}" → `"orgs/${encodePathSegment(id)}"`
* "/orgs/{id}/foo" → `"orgs/${encodePathSegment(id)}/foo"`
*/
export function buildKotlinPathExpression(rawPath: string): KotlinPathExpression {
const segments = parsePathTemplate(rawPath);
if (!hasPathParams(segments)) {
return { expression: ktLiteral(rawPath), requiresEncodeImport: false };
}

let body = '';
for (const seg of segments) {
if (seg.kind === 'literal') {
body += escapeKotlinStringLiteral(seg.value);
} else {
body += `\${encodePathSegment(${propertyName(seg.name)})}`;
}
}
return { expression: `"${body}"`, requiresEncodeImport: true };
}

function escapeKotlinStringLiteral(literal: string): string {
return literal.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$');
}
29 changes: 9 additions & 20 deletions src/kotlin/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { generateWrapperMethods } from './wrappers.js';
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
import { isHandwrittenOverride } from './overrides.js';
import { buildKotlinPathExpression, KOTLIN_PATH_ENCODE_IMPORT } from './path-expression.js';

const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';

Expand Down Expand Up @@ -113,6 +114,11 @@ function generateApiClass(
}
// Wrapper methods use bodyOf() for request body construction.
imports.add('com.workos.common.http.bodyOf');
// Wrappers share the operation's path; if it has any {param}, the
// wrapper emits encodePathSegment(...) and needs the import.
if (/\{[^{}]+\}/.test(resolvedOp!.operation.path)) {
imports.add(KOTLIN_PATH_ENCODE_IMPORT);
}
const wrapperLines = generateWrapperMethods(resolvedOp!, ctx);
if (body.length > 0) body.push('');
for (const line of wrapperLines) body.push(line);
Expand Down Expand Up @@ -374,7 +380,9 @@ function renderMethod(
(Object.keys(defaults).length > 0 || inferFromClient.length > 0) &&
specDeclaresBody);
const appendDefaultsAsQuery = !hasBody && (Object.keys(defaults).length > 0 || inferFromClient.length > 0);
const pathExpr = buildPathExpression(op.path, pathParams);
const pathBuilt = buildKotlinPathExpression(op.path);
const pathExpr = pathBuilt.expression;
if (pathBuilt.requiresEncodeImport) imports.add(KOTLIN_PATH_ENCODE_IMPORT);

if (
op.path === '/user_management/authenticate' &&
Expand Down Expand Up @@ -727,25 +735,6 @@ function _emitBodyField(field: Field, kotlinParamName: string, isPatch: boolean)
return [` if (${prop} != null) body[${ktLiteral(field.name)}] = ${prop}`];
}

function buildPathExpression(path: string, pathParams: Parameter[]): string {
if (pathParams.length === 0) return ktLiteral(path);
let result = path;
for (const pp of pathParams) {
const placeholder = `{${pp.name}}`;
const propName = propertyName(pp.name);
// Use $propName for simple identifiers and ${propName} only when followed by
// an ident-continuing char (to avoid false continuations). ktlint prefers the
// unbraced form for bare identifiers.
const replacement = isBareIdentifier(propName) ? `\$${propName}` : `\${${propName}}`;
result = result.replaceAll(placeholder, replacement);
}
return `"${result.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}

function isBareIdentifier(name: string): boolean {
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
}

function pickNamedQueryParam(sorted: Parameter[], name: string): string {
const match = sorted.find((p) => p.name === name);
return match ? propertyName(match.name) : 'null';
Expand Down
17 changes: 3 additions & 14 deletions src/kotlin/wrappers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { EmitterContext, ResolvedOperation, ResolvedWrapper, Parameter } from '@workos/oagen';
import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
import { className, propertyName, ktLiteral, clientFieldExpression, escapeReserved } from './naming.js';
import { mapTypeRef, mapTypeRefOptional } from './type-map.js';
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
import { sortPathParamsByTemplateOrder } from './resources.js';
import { buildKotlinPathExpression } from './path-expression.js';

/**
* Emit Kotlin wrapper methods for a union-split operation. Each wrapper
Expand Down Expand Up @@ -125,7 +126,7 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
lines.push(` val body = linkedMapOf<String, Any?>()`);
}

const pathExpr = buildPathExpr(op.path, pathParams);
const pathExpr = buildKotlinPathExpression(op.path).expression;
const httpMethod = op.httpMethod.toUpperCase();

lines.push(` val config =`);
Expand Down Expand Up @@ -154,15 +155,3 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
function escapeKdoc(s: string): string {
return s.replace(/\*\//g, '*\u200b/');
}

function buildPathExpr(path: string, pathParams: Parameter[]): string {
if (pathParams.length === 0) return ktLiteral(path);
let result = path;
for (const pp of pathParams) {
const placeholder = `{${pp.name}}`;
const propName = propertyName(pp.name);
const replacement = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(propName) ? `\$${propName}` : `\${${propName}}`;
result = result.replaceAll(placeholder, replacement);
}
return `"${result.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
37 changes: 37 additions & 0 deletions src/node/path-expression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { parsePathTemplate, hasPathParams, type PathSegment } from '../shared/path-template.js';
import { fieldName } from './naming.js';

/**
* Build the TypeScript expression that the SDK passes as the request path.
*
* Every {paramName} placeholder becomes `${encodeURIComponent(name)}` inside a
* template literal. encodeURIComponent is used (not encodeURI) because we want
* "/" to be encoded too — otherwise a caller-supplied id containing "../" can
* be normalized by the underlying HTTP transport (libcurl, fetch, etc.) into
* a different endpoint of the WorkOS API while still authenticated with the
* application's API key.
*
* "/orgs" → `'orgs'`
* "/orgs/{id}" → `` `orgs/${encodeURIComponent(id)}` ``
* "/orgs/{id}/foo" → `` `orgs/${encodeURIComponent(id)}/foo` ``
*/
export function buildNodePathExpression(rawPath: string): string {
const segments = parsePathTemplate(rawPath);
if (!hasPathParams(segments)) {
return `'${rawPath}'`;
}

let body = '';
for (const seg of segments) {
body += renderSegment(seg);
}
return `\`${body}\``;
}

function renderSegment(seg: PathSegment): string {
if (seg.kind === 'literal') {
// Template-literal-safe escapes: backtick, backslash, ${
return seg.value.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
}
return `\${encodeURIComponent(${fieldName(seg.name)})}`;
}
4 changes: 2 additions & 2 deletions src/node/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
getOpInferFromClient,
} from '../shared/resolved-ops.js';
import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
import { buildNodePathExpression } from './path-expression.js';
import { resolveWrapperParams } from '../shared/wrapper-utils.js';

/**
Expand Down Expand Up @@ -1417,8 +1418,7 @@ function renderQueryExpr(queryParams: { name: string; required: boolean }[]): st
}

function buildPathStr(op: Operation): string {
const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
return buildNodePathExpression(op.path);
}

function buildPathParams(op: Operation, specEnumNames?: Set<string>): string {
Expand Down
4 changes: 2 additions & 2 deletions src/node/wrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { toCamelCase } from '@workos/oagen';
import { fieldName, resolveInterfaceName, wireInterfaceName } from './naming.js';
import { mapTypeRef } from './type-map.js';
import { resolveWrapperParams, formatWrapperDescription } from '../shared/wrapper-utils.js';
import { buildNodePathExpression } from './path-expression.js';

/**
* Generate TypeScript wrapper method lines for union split operations.
Expand Down Expand Up @@ -157,8 +158,7 @@ function emitWrapperMethod(

/** Build a path template string from an Operation. */
function buildPathStr(op: { path: string; pathParams: Array<{ name: string }> }): string {
const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
return buildNodePathExpression(op.path);
}

/** Convert a JS value to a TypeScript literal. */
Expand Down
52 changes: 52 additions & 0 deletions src/php/path-expression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { parsePathTemplate, hasPathParams } from '../shared/path-template.js';
import { fieldName } from './naming.js';

export interface PhpPathOptions {
/**
* Raw OpenAPI param names whose runtime value is enum- or model-valued and
* therefore needs `->value` accessed before encoding. Mirrors the legacy
* resources.ts behavior that treated `kind === 'enum'` and `kind === 'model'`
* the same way.
*/
valueAccessorParams?: ReadonlySet<string>;
}

/**
* Build the PHP expression that the SDK passes to HttpClient as `path:`.
*
* Concatenation, not interpolation: PHP does not allow function calls inside
* "..." strings, and every parameter must be wrapped in `rawurlencode(...)` so
* that an unencoded "../" in a caller-supplied id cannot be normalized by
* libcurl into a different endpoint of the WorkOS API while still
* authenticated with the application's API key.
*
* "/orgs" → `'orgs'`
* "/orgs/{id}" → `'orgs/' . rawurlencode($id)`
* "/orgs/{id}/users" → `'orgs/' . rawurlencode($id) . '/users'`
* "/orgs/{id}" with id ∈ valueAccessorParams → `'orgs/' . rawurlencode($id->value)`
*/
export function buildPhpPathExpression(rawPath: string, options: PhpPathOptions = {}): string {
const segments = parsePathTemplate(rawPath, { stripLeadingSlash: true });
if (segments.length === 0) return "''";

if (!hasPathParams(segments)) {
return phpSingleQuoted((segments[0] as { value: string }).value);
}

const valueAccessor = options.valueAccessorParams;
const parts: string[] = [];
for (const seg of segments) {
if (seg.kind === 'literal') {
parts.push(phpSingleQuoted(seg.value));
} else {
const varName = fieldName(seg.name);
const accessor = valueAccessor?.has(seg.name) ? `$${varName}->value` : `$${varName}`;
parts.push(`rawurlencode(${accessor})`);
}
}
return parts.join(' . ');
}

function phpSingleQuoted(literal: string): string {
return `'${literal.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
}
15 changes: 4 additions & 11 deletions src/php/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '../shared/resolved-ops.js';
import { generateWrapperMethods } from './wrappers.js';
import { phpDocComment } from './utils.js';
import { buildPhpPathExpression } from './path-expression.js';

/**
* Resolve the resource class name for a service (used by client.ts).
Expand Down Expand Up @@ -734,19 +735,11 @@ function buildBodyParamMap(op: Operation, bodyModel: Model | null): Map<string,
}

function buildPathString(op: Operation): string {
let path = op.path.startsWith('/') ? op.path.slice(1) : op.path;
if (op.pathParams.length === 0) {
return `'${path}'`;
}
// Build a map of param name → PHP expression (with ->value for enum types)
const paramExprs = new Map<string, string>();
const valueAccessor = new Set<string>();
for (const p of op.pathParams) {
const phpName = fieldName(p.name);
const isEnum = p.type.kind === 'enum' || p.type.kind === 'model';
paramExprs.set(p.name, isEnum ? `{$${phpName}->value}` : `{$${phpName}}`);
if (p.type.kind === 'enum' || p.type.kind === 'model') valueAccessor.add(p.name);
}
path = path.replace(/\{([^}]+)\}/g, (_match, param) => paramExprs.get(param) ?? `{$${fieldName(param)}}`);
return `"${path}"`;
return buildPhpPathExpression(op.path, { valueAccessorParams: valueAccessor });
}

function isEnumType(ref: import('@workos/oagen').TypeRef): boolean {
Expand Down
8 changes: 3 additions & 5 deletions src/php/wrappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
import { className, fieldName } from './naming.js';
import { phpDocComment } from './utils.js';
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
import { buildPhpPathExpression } from './path-expression.js';

/**
* Generate PHP wrapper methods for split union operations.
Expand Down Expand Up @@ -108,15 +109,12 @@ function emitWrapperMethod(

// Delegate to HTTP client
const httpMethod = op.httpMethod.toUpperCase();
let path = op.path.startsWith('/') ? op.path.slice(1) : op.path;
const hasInterpolation = /\{[^}]+\}/.test(path);
path = path.replace(/\{([^}]+)\}/g, (_match, param) => `{$${fieldName(param)}}`);
const pathQuote = hasInterpolation ? '"' : "'";
const pathExpr = buildPhpPathExpression(op.path);

lines.push('');
lines.push(' $response = $this->client->request(');
lines.push(` method: '${httpMethod}',`);
lines.push(` path: ${pathQuote}${path}${pathQuote},`);
lines.push(` path: ${pathExpr},`);
lines.push(' body: $body,');
lines.push(' options: $options,');
lines.push(' );');
Expand Down
52 changes: 52 additions & 0 deletions src/python/path-expression.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { parsePathTemplate, hasPathParams } from '../shared/path-template.js';
import { fieldName } from './naming.js';

export interface PythonPathOptions {
/** Raw OpenAPI param names whose runtime value is enum-typed. */
enumParams?: ReadonlySet<string>;
}

/**
* Build the Python f-string that the SDK passes to the request layer.
*
* Every {paramName} placeholder is wrapped in
* `urllib.parse.quote(str(...), safe="")` so that an unencoded "/" or "../"
* in a caller-supplied id cannot be normalized by the underlying HTTP
* transport into a different endpoint of the WorkOS API while still
* authenticated with the application's API key. `safe=""` is critical:
* the stdlib default of `safe="/"` does NOT encode "/" and would leave the
* traversal vector open.
*
* Generated files using this helper must import `quote` (e.g.
* `from urllib.parse import quote`).
*
* "/orgs" → `"orgs"`
* "/orgs/{id}" → `f"orgs/{quote(str(id), safe='')}"`
* "/orgs/{id}" with id ∈ enums → `f"orgs/{quote(str(enum_value(id)), safe='')}"`
*/
export function buildPythonPathExpression(rawPath: string, options: PythonPathOptions = {}): string {
const segments = parsePathTemplate(rawPath, { stripLeadingSlash: true });
if (segments.length === 0) return '""';
if (!hasPathParams(segments)) {
const literal = (segments[0] as { value: string }).value;
return `"${escapePyDoubleQuoted(literal)}"`;
}

const enums = options.enumParams;
let body = '';
for (const seg of segments) {
if (seg.kind === 'literal') {
body += escapePyDoubleQuoted(seg.value);
} else {
const varName = fieldName(seg.name);
const inner = enums?.has(seg.name) ? `enum_value(${varName})` : varName;
body += `{quote(str(${inner}), safe='')}`;
}
}
return `f"${body}"`;
}

function escapePyDoubleQuoted(literal: string): string {
// f-strings: backslash, double-quote, and "{"/"}" all need escaping
return literal.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\{/g, '{{').replace(/\}/g, '}}');
}
Loading
Loading