diff --git a/generators/swift/sdk/src/generators/client/util/__test__/snapshots/formatted-endpoint-paths/path-body-property-collision.swift b/generators/swift/sdk/src/generators/client/util/__test__/snapshots/formatted-endpoint-paths/path-body-property-collision.swift
new file mode 100644
index 000000000000..4a2d9f5a42f1
--- /dev/null
+++ b/generators/swift/sdk/src/generators/client/util/__test__/snapshots/formatted-endpoint-paths/path-body-property-collision.swift
@@ -0,0 +1,2 @@
+// service_
+"/resources/\(key)/toggle"
\ No newline at end of file
diff --git a/generators/typescript/sdk/request-wrapper-generator/src/GeneratedRequestWrapperImpl.ts b/generators/typescript/sdk/request-wrapper-generator/src/GeneratedRequestWrapperImpl.ts
index 1e1e3905cf7d..7c0f8fdc2ab8 100644
--- a/generators/typescript/sdk/request-wrapper-generator/src/GeneratedRequestWrapperImpl.ts
+++ b/generators/typescript/sdk/request-wrapper-generator/src/GeneratedRequestWrapperImpl.ts
@@ -199,50 +199,10 @@ export class GeneratedRequestWrapperImpl implements GeneratedRequestWrapper {
public getRequestProperties(context: SdkContext): GeneratedRequestWrapper.Property[] {
const properties: GeneratedRequestWrapper.Property[] = [];
- for (const pathParameter of this.getPathParamsForRequestWrapper()) {
- const type = context.type.getReferenceToType(pathParameter.valueType);
- const hasDefaultValue = this.hasDefaultValue(pathParameter.valueType, context);
- const propertyName = this.getPropertyNameOfPathParameter(pathParameter);
- properties.push({
- name: getPropertyKey(propertyName.propertyName),
- safeName: getPropertyKey(propertyName.safeName),
- type: type.typeNodeWithoutUndefined,
- isOptional: type.isOptional || hasDefaultValue,
- docs: pathParameter.docs != null ? [pathParameter.docs] : undefined
- });
- }
-
- for (const queryParameter of this.getAllQueryParameters()) {
- const type = context.type.getReferenceToType(queryParameter.valueType);
- const hasDefaultValue = this.hasDefaultValue(queryParameter.valueType, context);
- const propertyName = this.getPropertyNameOfQueryParameter(queryParameter);
- properties.push({
- name: getPropertyKey(propertyName.propertyName),
- safeName: getPropertyKey(propertyName.safeName),
- type: queryParameter.allowMultiple
- ? ts.factory.createUnionTypeNode([
- type.typeNodeWithoutUndefined,
- ts.factory.createArrayTypeNode(type.typeNodeWithoutUndefined)
- ])
- : type.typeNodeWithoutUndefined,
- isOptional: type.isOptional || hasDefaultValue,
- docs: queryParameter.docs != null ? [queryParameter.docs] : undefined
- });
- }
-
- for (const header of this.getAllNonLiteralHeaders(context)) {
- const type = context.type.getReferenceToType(header.valueType);
- const hasDefaultValue = this.hasDefaultValue(header.valueType, context);
- const headerName = this.getPropertyNameOfNonLiteralHeader(header);
- properties.push({
- name: getPropertyKey(headerName.propertyName),
- safeName: getPropertyKey(headerName.safeName),
- type: type.typeNodeWithoutUndefined,
- isOptional: type.isOptional || hasDefaultValue,
- docs: header.docs != null ? [header.docs] : undefined
- });
- }
-
+ // First, collect body properties to build a set of names that should not be duplicated
+ // from path/query/header parameters. This handles the case where a body property has
+ // the same name as a path parameter (e.g., both have a "key" field).
+ const bodyProperties: GeneratedRequestWrapper.Property[] = [];
const requestBody = this.endpoint.requestBody;
if (requestBody != null) {
HttpRequestBody._visit(requestBody, {
@@ -252,14 +212,14 @@ export class GeneratedRequestWrapperImpl implements GeneratedRequestWrapper {
inlinedRequestBody,
context
);
- properties.push(...inlinedProperties);
+ bodyProperties.push(...inlinedProperties);
} else {
for (const property of this.getAllNonLiteralPropertiesFromInlinedRequest({
inlinedRequestBody,
context
})) {
const requestProperty = this.getInlineProperty(inlinedRequestBody, property, context);
- properties.push(requestProperty);
+ bodyProperties.push(requestProperty);
}
}
},
@@ -269,7 +229,7 @@ export class GeneratedRequestWrapperImpl implements GeneratedRequestWrapper {
referenceToRequestBody,
context
);
- properties.push(...referencedProperties);
+ bodyProperties.push(...referencedProperties);
} else {
const type = context.type.getReferenceToType(referenceToRequestBody.requestBodyType);
const name = this.getReferencedBodyPropertyName();
@@ -280,7 +240,7 @@ export class GeneratedRequestWrapperImpl implements GeneratedRequestWrapper {
isOptional: type.isOptional,
docs: referenceToRequestBody.docs != null ? [referenceToRequestBody.docs] : undefined
};
- properties.push(requestProperty);
+ bodyProperties.push(requestProperty);
}
},
fileUpload: (fileUploadRequest) => {
@@ -291,7 +251,7 @@ export class GeneratedRequestWrapperImpl implements GeneratedRequestWrapper {
return;
}
const propertyName = this.getPropertyNameOfFileParameterFromName(fileProperty.key);
- properties.push({
+ bodyProperties.push({
name: getPropertyKey(propertyName.propertyName),
safeName: getPropertyKey(propertyName.safeName),
type: this.getFileParameterType(fileProperty, context),
@@ -300,7 +260,9 @@ export class GeneratedRequestWrapperImpl implements GeneratedRequestWrapper {
});
},
bodyProperty: (inlinedProperty) => {
- properties.push(this.getInlineProperty(fileUploadRequest, inlinedProperty, context));
+ bodyProperties.push(
+ this.getInlineProperty(fileUploadRequest, inlinedProperty, context)
+ );
},
_other: () => {
throw new Error("Unknown FileUploadRequestProperty: " + property.type);
@@ -317,6 +279,72 @@ export class GeneratedRequestWrapperImpl implements GeneratedRequestWrapper {
});
}
+ // Build a set of body property names to skip duplicates from path/query/header parameters.
+ // We use `name` (the actual interface property key) for deduplication, not `safeName`.
+ const bodyPropertyNames = new Set(bodyProperties.map((p) => p.name));
+
+ // Add path parameters, skipping any that conflict with body properties
+ for (const pathParameter of this.getPathParamsForRequestWrapper()) {
+ const propertyName = this.getPropertyNameOfPathParameter(pathParameter);
+ const name = getPropertyKey(propertyName.propertyName);
+ if (bodyPropertyNames.has(name)) {
+ continue;
+ }
+ const type = context.type.getReferenceToType(pathParameter.valueType);
+ const hasDefaultValue = this.hasDefaultValue(pathParameter.valueType, context);
+ properties.push({
+ name,
+ safeName: getPropertyKey(propertyName.safeName),
+ type: type.typeNodeWithoutUndefined,
+ isOptional: type.isOptional || hasDefaultValue,
+ docs: pathParameter.docs != null ? [pathParameter.docs] : undefined
+ });
+ }
+
+ // Add query parameters, skipping any that conflict with body properties
+ for (const queryParameter of this.getAllQueryParameters()) {
+ const propertyName = this.getPropertyNameOfQueryParameter(queryParameter);
+ const name = getPropertyKey(propertyName.propertyName);
+ if (bodyPropertyNames.has(name)) {
+ continue;
+ }
+ const type = context.type.getReferenceToType(queryParameter.valueType);
+ const hasDefaultValue = this.hasDefaultValue(queryParameter.valueType, context);
+ properties.push({
+ name,
+ safeName: getPropertyKey(propertyName.safeName),
+ type: queryParameter.allowMultiple
+ ? ts.factory.createUnionTypeNode([
+ type.typeNodeWithoutUndefined,
+ ts.factory.createArrayTypeNode(type.typeNodeWithoutUndefined)
+ ])
+ : type.typeNodeWithoutUndefined,
+ isOptional: type.isOptional || hasDefaultValue,
+ docs: queryParameter.docs != null ? [queryParameter.docs] : undefined
+ });
+ }
+
+ // Add headers, skipping any that conflict with body properties
+ for (const header of this.getAllNonLiteralHeaders(context)) {
+ const headerName = this.getPropertyNameOfNonLiteralHeader(header);
+ const name = getPropertyKey(headerName.propertyName);
+ if (bodyPropertyNames.has(name)) {
+ continue;
+ }
+ const type = context.type.getReferenceToType(header.valueType);
+ const hasDefaultValue = this.hasDefaultValue(header.valueType, context);
+ properties.push({
+ name,
+ safeName: getPropertyKey(headerName.safeName),
+ type: type.typeNodeWithoutUndefined,
+ isOptional: type.isOptional || hasDefaultValue,
+ docs: header.docs != null ? [header.docs] : undefined
+ });
+ }
+
+ // Add body properties at the end
+ properties.push(...bodyProperties);
+
return properties;
}
diff --git a/generators/typescript/sdk/versions.yml b/generators/typescript/sdk/versions.yml
index 2afd1ff373d7..f55618e5437f 100644
--- a/generators/typescript/sdk/versions.yml
+++ b/generators/typescript/sdk/versions.yml
@@ -1,4 +1,15 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
+- version: 3.37.1
+ changelogEntry:
+ - summary: |
+ Fix duplicate properties in request wrapper interfaces when a path parameter has the same name as a body property.
+ Previously, if an endpoint had a path parameter (e.g., `key`) and the request body also contained a property with the same name,
+ the generated request wrapper interface would have duplicate properties, causing TypeScript compilation errors.
+ The fix ensures that body properties take precedence, and duplicate path/query/header parameters are skipped.
+ type: fix
+ createdAt: "2025-12-08"
+ irVersion: 62
+
- version: 3.37.0
changelogEntry:
- summary: |
diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/path-body-property-collision/type__ToggleResponse.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/path-body-property-collision/type__ToggleResponse.json
new file mode 100644
index 000000000000..0da93297fa92
--- /dev/null
+++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/path-body-property-collision/type__ToggleResponse.json
@@ -0,0 +1,17 @@
+{
+ "type": "object",
+ "properties": {
+ "success": {
+ "oneOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "definitions": {}
+}
\ No newline at end of file
diff --git a/seed/ruby-sdk/seed.yml b/seed/ruby-sdk/seed.yml
index 5b07a4e44d3b..319151719ba2 100644
--- a/seed/ruby-sdk/seed.yml
+++ b/seed/ruby-sdk/seed.yml
@@ -121,6 +121,7 @@ allowedFailures:
- package-yml
- pagination
- pagination-custom
+ - path-body-property-collision
- path-parameters
- plain-text
- property-access
diff --git a/seed/ts-sdk/path-body-property-collision/.fern/metadata.json b/seed/ts-sdk/path-body-property-collision/.fern/metadata.json
new file mode 100644
index 000000000000..6b90fc3cffab
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/.fern/metadata.json
@@ -0,0 +1,5 @@
+{
+ "cliVersion": "DUMMY",
+ "generatorName": "fernapi/fern-typescript-sdk",
+ "generatorVersion": "latest"
+}
diff --git a/seed/ts-sdk/path-body-property-collision/.github/workflows/ci.yml b/seed/ts-sdk/path-body-property-collision/.github/workflows/ci.yml
new file mode 100644
index 000000000000..836106996595
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/.github/workflows/ci.yml
@@ -0,0 +1,78 @@
+name: ci
+
+on: [push]
+
+jobs:
+ compile:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v6
+
+ - name: Set up node
+ uses: actions/setup-node@v6
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Compile
+ run: pnpm build
+
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v6
+
+ - name: Set up node
+ uses: actions/setup-node@v6
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Test
+ run: pnpm test
+
+ publish:
+ needs: [ compile, test ]
+ if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v6
+
+ - name: Set up node
+ uses: actions/setup-node@v6
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Build
+ run: pnpm build
+
+ - name: Publish to npm
+ run: |
+ npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN}
+ publish() { # use latest npm to ensure OIDC support
+ npx -y npm@latest publish "$@"
+ }
+ if [[ ${GITHUB_REF} == *alpha* ]]; then
+ publish --access public --tag alpha
+ elif [[ ${GITHUB_REF} == *beta* ]]; then
+ publish --access public --tag beta
+ else
+ publish --access public
+ fi
+ env:
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
\ No newline at end of file
diff --git a/seed/ts-sdk/path-body-property-collision/.gitignore b/seed/ts-sdk/path-body-property-collision/.gitignore
new file mode 100644
index 000000000000..72271e049c02
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/.gitignore
@@ -0,0 +1,3 @@
+node_modules
+.DS_Store
+/dist
\ No newline at end of file
diff --git a/seed/ts-sdk/path-body-property-collision/CONTRIBUTING.md b/seed/ts-sdk/path-body-property-collision/CONTRIBUTING.md
new file mode 100644
index 000000000000..fe5bc2f77e0b
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/CONTRIBUTING.md
@@ -0,0 +1,133 @@
+# Contributing
+
+Thanks for your interest in contributing to this SDK! This document provides guidelines for contributing to the project.
+
+## Getting Started
+
+### Prerequisites
+
+- Node.js 20 or higher
+- pnpm package manager
+
+### Installation
+
+Install the project dependencies:
+
+```bash
+pnpm install
+```
+
+### Building
+
+Build the project:
+
+```bash
+pnpm build
+```
+
+### Testing
+
+Run the test suite:
+
+```bash
+pnpm test
+```
+
+Run specific test types:
+- `pnpm test:unit` - Run unit tests
+- `pnpm test:wire` - Run wire/integration tests
+
+### Linting and Formatting
+
+Check code style:
+
+```bash
+pnpm run lint
+pnpm run format:check
+```
+
+Fix code style issues:
+
+```bash
+pnpm run lint:fix
+pnpm run format:fix
+```
+
+Or use the combined check command:
+
+```bash
+pnpm run check:fix
+```
+
+## About Generated Code
+
+**Important**: Most files in this SDK are automatically generated by [Fern](https://buildwithfern.com) from the API definition. Direct modifications to generated files will be overwritten the next time the SDK is generated.
+
+### Generated Files
+
+The following directories contain generated code:
+- `src/api/` - API client classes and types
+- `src/serialization/` - Serialization/deserialization logic
+- Most TypeScript files in `src/`
+
+### How to Customize
+
+If you need to customize the SDK, you have two options:
+
+#### Option 1: Use `.fernignore`
+
+For custom code that should persist across SDK regenerations:
+
+1. Create a `.fernignore` file in the project root
+2. Add file patterns for files you want to preserve (similar to `.gitignore` syntax)
+3. Add your custom code to those files
+
+Files listed in `.fernignore` will not be overwritten when the SDK is regenerated.
+
+For more information, see the [Fern documentation on custom code](https://buildwithfern.com/learn/sdks/overview/custom-code).
+
+#### Option 2: Contribute to the Generator
+
+If you want to change how code is generated for all users of this SDK:
+
+1. The TypeScript SDK generator lives in the [Fern repository](https://github.com/fern-api/fern)
+2. Generator code is located at `generators/typescript/sdk/`
+3. Follow the [Fern contributing guidelines](https://github.com/fern-api/fern/blob/main/CONTRIBUTING.md)
+4. Submit a pull request with your changes to the generator
+
+This approach is best for:
+- Bug fixes in generated code
+- New features that would benefit all users
+- Improvements to code generation patterns
+
+## Making Changes
+
+### Workflow
+
+1. Create a new branch for your changes
+2. Make your modifications
+3. Run tests to ensure nothing breaks: `pnpm test`
+4. Run linting and formatting: `pnpm run check:fix`
+5. Build the project: `pnpm build`
+6. Commit your changes with a clear commit message
+7. Push your branch and create a pull request
+
+### Commit Messages
+
+Write clear, descriptive commit messages that explain what changed and why.
+
+### Code Style
+
+This project uses automated code formatting and linting. Run `pnpm run check:fix` before committing to ensure your code meets the project's style guidelines.
+
+## Questions or Issues?
+
+If you have questions or run into issues:
+
+1. Check the [Fern documentation](https://buildwithfern.com)
+2. Search existing [GitHub issues](https://github.com/fern-api/fern/issues)
+3. Open a new issue if your question hasn't been addressed
+
+## License
+
+By contributing to this project, you agree that your contributions will be licensed under the same license as the project.
diff --git a/seed/ts-sdk/path-body-property-collision/README.md b/seed/ts-sdk/path-body-property-collision/README.md
new file mode 100644
index 000000000000..6813fa4df054
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/README.md
@@ -0,0 +1,245 @@
+# Seed TypeScript Library
+
+[](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FTypeScript)
+[](https://www.npmjs.com/package/@fern/path-body-property-collision)
+
+The Seed TypeScript library provides convenient access to the Seed APIs from TypeScript.
+
+## Installation
+
+```sh
+npm i -s @fern/path-body-property-collision
+```
+
+## Reference
+
+A full reference for this library is available [here](./reference.md).
+
+## Usage
+
+Instantiate and use the client with the following:
+
+```typescript
+import { SeedApiClient } from "@fern/path-body-property-collision";
+
+const client = new SeedApiClient({ environment: "YOUR_BASE_URL" });
+await client.toggleResource({
+ key: "key",
+ key: "key",
+ enabled: true
+});
+```
+
+## Request And Response Types
+
+The SDK exports all request and response types as TypeScript interfaces. Simply import them with the
+following namespace:
+
+```typescript
+import { SeedApi } from "@fern/path-body-property-collision";
+
+const request: SeedApi.ToggleRequest = {
+ ...
+};
+```
+
+## Exception Handling
+
+When the API returns a non-success status code (4xx or 5xx response), a subclass of the following error
+will be thrown.
+
+```typescript
+import { SeedApiError } from "@fern/path-body-property-collision";
+
+try {
+ await client.toggleResource(...);
+} catch (err) {
+ if (err instanceof SeedApiError) {
+ console.log(err.statusCode);
+ console.log(err.message);
+ console.log(err.body);
+ console.log(err.rawResponse);
+ }
+}
+```
+
+## Advanced
+
+### Additional Headers
+
+If you would like to send additional headers as part of the request, use the `headers` request option.
+
+```typescript
+const response = await client.toggleResource(..., {
+ headers: {
+ 'X-Custom-Header': 'custom value'
+ }
+});
+```
+
+### Additional Query String Parameters
+
+If you would like to send additional query string parameters as part of the request, use the `queryParams` request option.
+
+```typescript
+const response = await client.toggleResource(..., {
+ queryParams: {
+ 'customQueryParamKey': 'custom query param value'
+ }
+});
+```
+
+### Retries
+
+The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long
+as the request is deemed retryable and the number of retry attempts has not grown larger than the configured
+retry limit (default: 2).
+
+A request is deemed retryable when any of the following HTTP status codes is returned:
+
+- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout)
+- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests)
+- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors)
+
+Use the `maxRetries` request option to configure this behavior.
+
+```typescript
+const response = await client.toggleResource(..., {
+ maxRetries: 0 // override maxRetries at the request level
+});
+```
+
+### Timeouts
+
+The SDK defaults to a 60 second timeout. Use the `timeoutInSeconds` option to configure this behavior.
+
+```typescript
+const response = await client.toggleResource(..., {
+ timeoutInSeconds: 30 // override timeout to 30s
+});
+```
+
+### Aborting Requests
+
+The SDK allows users to abort requests at any point by passing in an abort signal.
+
+```typescript
+const controller = new AbortController();
+const response = await client.toggleResource(..., {
+ abortSignal: controller.signal
+});
+controller.abort(); // aborts the request
+```
+
+### Access Raw Response Data
+
+The SDK provides access to raw response data, including headers, through the `.withRawResponse()` method.
+The `.withRawResponse()` method returns a promise that results to an object with a `data` and a `rawResponse` property.
+
+```typescript
+const { data, rawResponse } = await client.toggleResource(...).withRawResponse();
+
+console.log(data);
+console.log(rawResponse.headers['X-My-Header']);
+```
+
+### Logging
+
+The SDK supports logging. You can configure the logger by passing in a `logging` object to the client options.
+
+```typescript
+import { SeedApiClient, logging } from "@fern/path-body-property-collision";
+
+const client = new SeedApiClient({
+ ...
+ logging: {
+ level: logging.LogLevel.Debug, // defaults to logging.LogLevel.Info
+ logger: new logging.ConsoleLogger(), // defaults to ConsoleLogger
+ silent: false, // defaults to true, set to false to enable logging
+ }
+});
+```
+The `logging` object can have the following properties:
+- `level`: The log level to use. Defaults to `logging.LogLevel.Info`.
+- `logger`: The logger to use. Defaults to a `logging.ConsoleLogger`.
+- `silent`: Whether to silence the logger. Defaults to `true`.
+
+The `level` property can be one of the following values:
+- `logging.LogLevel.Debug`
+- `logging.LogLevel.Info`
+- `logging.LogLevel.Warn`
+- `logging.LogLevel.Error`
+
+To provide a custom logger, you can pass in an object that implements the `logging.ILogger` interface.
+
+
+Custom logger examples
+
+Here's an example using the popular `winston` logging library.
+```ts
+import winston from 'winston';
+
+const winstonLogger = winston.createLogger({...});
+
+const logger: logging.ILogger = {
+ debug: (msg, ...args) => winstonLogger.debug(msg, ...args),
+ info: (msg, ...args) => winstonLogger.info(msg, ...args),
+ warn: (msg, ...args) => winstonLogger.warn(msg, ...args),
+ error: (msg, ...args) => winstonLogger.error(msg, ...args),
+};
+```
+
+Here's an example using the popular `pino` logging library.
+
+```ts
+import pino from 'pino';
+
+const pinoLogger = pino({...});
+
+const logger: logging.ILogger = {
+ debug: (msg, ...args) => pinoLogger.debug(args, msg),
+ info: (msg, ...args) => pinoLogger.info(args, msg),
+ warn: (msg, ...args) => pinoLogger.warn(args, msg),
+ error: (msg, ...args) => pinoLogger.error(args, msg),
+};
+```
+
+
+
+### Runtime Compatibility
+
+
+The SDK works in the following runtimes:
+
+
+
+- Node.js 18+
+- Vercel
+- Cloudflare Workers
+- Deno v1.25+
+- Bun 1.0+
+- React Native
+
+### Customizing Fetch Client
+
+The SDK provides a way for you to customize the underlying HTTP client / Fetch function. If you're running in an
+unsupported environment, this provides a way for you to break glass and ensure the SDK works.
+
+```typescript
+import { SeedApiClient } from "@fern/path-body-property-collision";
+
+const client = new SeedApiClient({
+ ...
+ fetcher: // provide your implementation here
+});
+```
+
+## Contributing
+
+While we value open-source contributions to this SDK, this library is generated programmatically.
+Additions made directly to this library would have to be moved over to our generation code,
+otherwise they would be overwritten upon the next generated release. Feel free to open a PR as
+a proof of concept, but know that we will not be able to merge it as-is. We suggest opening
+an issue first to discuss with us!
+
+On the other hand, contributions to the README are always very welcome!
\ No newline at end of file
diff --git a/seed/ts-sdk/path-body-property-collision/biome.json b/seed/ts-sdk/path-body-property-collision/biome.json
new file mode 100644
index 000000000000..a777468e4ae2
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/biome.json
@@ -0,0 +1,74 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.3.1/schema.json",
+ "root": true,
+ "vcs": {
+ "enabled": false
+ },
+ "files": {
+ "ignoreUnknown": true,
+ "includes": [
+ "**",
+ "!!dist",
+ "!!**/dist",
+ "!!lib",
+ "!!**/lib",
+ "!!_tmp_*",
+ "!!**/_tmp_*",
+ "!!*.tmp",
+ "!!**/*.tmp",
+ "!!.tmp/",
+ "!!**/.tmp/",
+ "!!*.log",
+ "!!**/*.log",
+ "!!**/.DS_Store",
+ "!!**/Thumbs.db"
+ ]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space",
+ "indentWidth": 4,
+ "lineWidth": 120
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double"
+ }
+ },
+ "assist": {
+ "enabled": true,
+ "actions": {
+ "source": {
+ "organizeImports": "on"
+ }
+ }
+ },
+ "linter": {
+ "rules": {
+ "style": {
+ "useNodejsImportProtocol": "off"
+ },
+ "suspicious": {
+ "noAssignInExpressions": "warn",
+ "noUselessEscapeInString": {
+ "level": "warn",
+ "fix": "none",
+ "options": {}
+ },
+ "noThenProperty": "warn",
+ "useIterableCallbackReturn": "warn",
+ "noShadowRestrictedNames": "warn",
+ "noTsIgnore": {
+ "level": "warn",
+ "fix": "none",
+ "options": {}
+ },
+ "noConfusingVoidType": {
+ "level": "warn",
+ "fix": "none",
+ "options": {}
+ }
+ }
+ }
+ }
+}
diff --git a/seed/ts-sdk/path-body-property-collision/package.json b/seed/ts-sdk/path-body-property-collision/package.json
new file mode 100644
index 000000000000..c3a2297b02ff
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/package.json
@@ -0,0 +1,66 @@
+{
+ "name": "@fern/path-body-property-collision",
+ "version": "0.0.1",
+ "private": false,
+ "repository": "github:path-body-property-collision/fern",
+ "type": "commonjs",
+ "main": "./dist/cjs/index.js",
+ "module": "./dist/esm/index.mjs",
+ "types": "./dist/cjs/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/cjs/index.d.ts",
+ "import": {
+ "types": "./dist/esm/index.d.mts",
+ "default": "./dist/esm/index.mjs"
+ },
+ "require": {
+ "types": "./dist/cjs/index.d.ts",
+ "default": "./dist/cjs/index.js"
+ },
+ "default": "./dist/cjs/index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "dist",
+ "reference.md",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "format": "biome format --write --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none",
+ "format:check": "biome format --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none",
+ "lint": "biome lint --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none",
+ "lint:fix": "biome lint --fix --unsafe --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none",
+ "check": "biome check --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none",
+ "check:fix": "biome check --fix --unsafe --skip-parse-errors --no-errors-on-unmatched --max-diagnostics=none",
+ "build": "pnpm build:cjs && pnpm build:esm",
+ "build:cjs": "tsc --project ./tsconfig.cjs.json",
+ "build:esm": "tsc --project ./tsconfig.esm.json && node scripts/rename-to-esm-files.js dist/esm",
+ "test": "vitest",
+ "test:unit": "vitest --project unit",
+ "test:wire": "vitest --project wire"
+ },
+ "dependencies": {},
+ "devDependencies": {
+ "webpack": "^5.97.1",
+ "ts-loader": "^9.5.1",
+ "vitest": "^3.2.4",
+ "msw": "2.11.2",
+ "@types/node": "^18.19.70",
+ "typescript": "~5.7.2",
+ "@biomejs/biome": "2.3.1"
+ },
+ "browser": {
+ "fs": false,
+ "os": false,
+ "path": false,
+ "stream": false
+ },
+ "packageManager": "pnpm@10.20.0",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "sideEffects": false
+}
diff --git a/seed/ts-sdk/path-body-property-collision/pnpm-workspace.yaml b/seed/ts-sdk/path-body-property-collision/pnpm-workspace.yaml
new file mode 100644
index 000000000000..6e4c395107df
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/pnpm-workspace.yaml
@@ -0,0 +1 @@
+packages: ['.']
\ No newline at end of file
diff --git a/seed/ts-sdk/path-body-property-collision/reference.md b/seed/ts-sdk/path-body-property-collision/reference.md
new file mode 100644
index 000000000000..5c447acbf739
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/reference.md
@@ -0,0 +1,70 @@
+# Reference
+client.toggleResource({ ...params }) -> SeedApi.ToggleResponse
+
+-
+
+#### 📝 Description
+
+
+-
+
+
+-
+
+This endpoint has a path parameter 'key' and a request body that also contains a 'key' property.
+The generated request wrapper should only have one 'key' property (from the body).
+
+
+
+
+
+#### 🔌 Usage
+
+
+-
+
+
+-
+
+```typescript
+await client.toggleResource({
+ key: "key",
+ key: "key",
+ enabled: true
+});
+
+```
+
+
+
+
+
+#### ⚙️ Parameters
+
+
+-
+
+
+-
+
+**request:** `SeedApi.ToggleRequest`
+
+
+
+
+
+-
+
+**requestOptions:** `SeedApiClient.RequestOptions`
+
+
+
+
+
+
+
+
+
+
+
+##
\ No newline at end of file
diff --git a/seed/ts-sdk/path-body-property-collision/scripts/rename-to-esm-files.js b/seed/ts-sdk/path-body-property-collision/scripts/rename-to-esm-files.js
new file mode 100644
index 000000000000..dc1df1cbbacb
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/scripts/rename-to-esm-files.js
@@ -0,0 +1,123 @@
+#!/usr/bin/env node
+
+const fs = require("fs").promises;
+const path = require("path");
+
+const extensionMap = {
+ ".js": ".mjs",
+ ".d.ts": ".d.mts",
+};
+const oldExtensions = Object.keys(extensionMap);
+
+async function findFiles(rootPath) {
+ const files = [];
+
+ async function scan(directory) {
+ const entries = await fs.readdir(directory, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = path.join(directory, entry.name);
+
+ if (entry.isDirectory()) {
+ if (entry.name !== "node_modules" && !entry.name.startsWith(".")) {
+ await scan(fullPath);
+ }
+ } else if (entry.isFile()) {
+ if (oldExtensions.some((ext) => entry.name.endsWith(ext))) {
+ files.push(fullPath);
+ }
+ }
+ }
+ }
+
+ await scan(rootPath);
+ return files;
+}
+
+async function updateFiles(files) {
+ const updatedFiles = [];
+ for (const file of files) {
+ const updated = await updateFileContents(file);
+ updatedFiles.push(updated);
+ }
+
+ console.log(`Updated imports in ${updatedFiles.length} files.`);
+}
+
+async function updateFileContents(file) {
+ const content = await fs.readFile(file, "utf8");
+
+ let newContent = content;
+ // Update each extension type defined in the map
+ for (const [oldExt, newExt] of Object.entries(extensionMap)) {
+ // Handle static imports/exports
+ const staticRegex = new RegExp(`(import|export)(.+from\\s+['"])(\\.\\.?\\/[^'"]+)(\\${oldExt})(['"])`, "g");
+ newContent = newContent.replace(staticRegex, `$1$2$3${newExt}$5`);
+
+ // Handle dynamic imports (yield import, await import, regular import())
+ const dynamicRegex = new RegExp(
+ `(yield\\s+import|await\\s+import|import)\\s*\\(\\s*['"](\\.\\.\?\\/[^'"]+)(\\${oldExt})['"]\\s*\\)`,
+ "g",
+ );
+ newContent = newContent.replace(dynamicRegex, `$1("$2${newExt}")`);
+ }
+
+ if (content !== newContent) {
+ await fs.writeFile(file, newContent, "utf8");
+ return true;
+ }
+ return false;
+}
+
+async function renameFiles(files) {
+ let counter = 0;
+ for (const file of files) {
+ const ext = oldExtensions.find((ext) => file.endsWith(ext));
+ const newExt = extensionMap[ext];
+
+ if (newExt) {
+ const newPath = file.slice(0, -ext.length) + newExt;
+ await fs.rename(file, newPath);
+ counter++;
+ }
+ }
+
+ console.log(`Renamed ${counter} files.`);
+}
+
+async function main() {
+ try {
+ const targetDir = process.argv[2];
+ if (!targetDir) {
+ console.error("Please provide a target directory");
+ process.exit(1);
+ }
+
+ const targetPath = path.resolve(targetDir);
+ const targetStats = await fs.stat(targetPath);
+
+ if (!targetStats.isDirectory()) {
+ console.error("The provided path is not a directory");
+ process.exit(1);
+ }
+
+ console.log(`Scanning directory: ${targetDir}`);
+
+ const files = await findFiles(targetDir);
+
+ if (files.length === 0) {
+ console.log("No matching files found.");
+ process.exit(0);
+ }
+
+ console.log(`Found ${files.length} files.`);
+ await updateFiles(files);
+ await renameFiles(files);
+ console.log("\nDone!");
+ } catch (error) {
+ console.error("An error occurred:", error.message);
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/seed/ts-sdk/path-body-property-collision/snippet.json b/seed/ts-sdk/path-body-property-collision/snippet.json
new file mode 100644
index 000000000000..12862b11498d
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/snippet.json
@@ -0,0 +1,16 @@
+{
+ "endpoints": [
+ {
+ "id": {
+ "path": "/resources/{key}/toggle",
+ "method": "POST",
+ "identifier_override": "endpoint_.toggleResource"
+ },
+ "snippet": {
+ "type": "typescript",
+ "client": "import { SeedApiClient } from \"@fern/path-body-property-collision\";\n\nconst client = new SeedApiClient({ environment: \"YOUR_BASE_URL\" });\nawait client.toggleResource({\n key: \"key\",\n key: \"key\",\n enabled: true\n});\n"
+ }
+ }
+ ],
+ "types": {}
+}
\ No newline at end of file
diff --git a/seed/ts-sdk/path-body-property-collision/src/BaseClient.ts b/seed/ts-sdk/path-body-property-collision/src/BaseClient.ts
new file mode 100644
index 000000000000..5b8c8d6ae864
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/BaseClient.ts
@@ -0,0 +1,57 @@
+// This file was auto-generated by Fern from our API Definition.
+
+import { mergeHeaders } from "./core/headers.js";
+import * as core from "./core/index.js";
+
+export interface BaseClientOptions {
+ environment: core.Supplier;
+ /** Specify a custom URL to connect the client to. */
+ baseUrl?: core.Supplier;
+ /** Additional headers to include in requests. */
+ headers?: Record | null | undefined>;
+ /** The default maximum time to wait for a response in seconds. */
+ timeoutInSeconds?: number;
+ /** The default number of times to retry the request. Defaults to 2. */
+ maxRetries?: number;
+ /** Provide a custom fetch implementation. Useful for platforms that don't have a built-in fetch or need a custom implementation. */
+ fetch?: typeof fetch;
+ /** Configure logging for the client. */
+ logging?: core.logging.LogConfig | core.logging.Logger;
+}
+
+export interface BaseRequestOptions {
+ /** The maximum time to wait for a response in seconds. */
+ timeoutInSeconds?: number;
+ /** The number of times to retry the request. Defaults to 2. */
+ maxRetries?: number;
+ /** A hook to abort the request. */
+ abortSignal?: AbortSignal;
+ /** Additional query string parameters to include in the request. */
+ queryParams?: Record;
+ /** Additional headers to include in the request. */
+ headers?: Record | null | undefined>;
+}
+
+export type NormalizedClientOptions = T & {
+ logging: core.logging.Logger;
+};
+
+export function normalizeClientOptions(options: T): NormalizedClientOptions {
+ const headers = mergeHeaders(
+ {
+ "X-Fern-Language": "JavaScript",
+ "X-Fern-SDK-Name": "@fern/path-body-property-collision",
+ "X-Fern-SDK-Version": "0.0.1",
+ "User-Agent": "@fern/path-body-property-collision/0.0.1",
+ "X-Fern-Runtime": core.RUNTIME.type,
+ "X-Fern-Runtime-Version": core.RUNTIME.version,
+ },
+ options?.headers,
+ );
+
+ return {
+ ...options,
+ logging: core.logging.createLogger(options?.logging),
+ headers,
+ } as NormalizedClientOptions;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/Client.ts b/seed/ts-sdk/path-body-property-collision/src/Client.ts
new file mode 100644
index 000000000000..e0c48d2756c9
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/Client.ts
@@ -0,0 +1,83 @@
+// This file was auto-generated by Fern from our API Definition.
+
+import type * as SeedApi from "./api/index.js";
+import type { BaseClientOptions, BaseRequestOptions } from "./BaseClient.js";
+import { type NormalizedClientOptions, normalizeClientOptions } from "./BaseClient.js";
+import { mergeHeaders } from "./core/headers.js";
+import * as core from "./core/index.js";
+import { handleNonStatusCodeError } from "./errors/handleNonStatusCodeError.js";
+import * as errors from "./errors/index.js";
+
+export declare namespace SeedApiClient {
+ export type Options = BaseClientOptions;
+
+ export interface RequestOptions extends BaseRequestOptions {}
+}
+
+export class SeedApiClient {
+ protected readonly _options: NormalizedClientOptions;
+
+ constructor(options: SeedApiClient.Options) {
+ this._options = normalizeClientOptions(options);
+ }
+
+ /**
+ * This endpoint has a path parameter 'key' and a request body that also contains a 'key' property.
+ * The generated request wrapper should only have one 'key' property (from the body).
+ *
+ * @param {SeedApi.ToggleRequest} request
+ * @param {SeedApiClient.RequestOptions} requestOptions - Request-specific configuration.
+ *
+ * @example
+ * await client.toggleResource({
+ * key: "key",
+ * key: "key",
+ * enabled: true
+ * })
+ */
+ public toggleResource(
+ request: SeedApi.ToggleRequest,
+ requestOptions?: SeedApiClient.RequestOptions,
+ ): core.HttpResponsePromise {
+ return core.HttpResponsePromise.fromPromise(this.__toggleResource(request, requestOptions));
+ }
+
+ private async __toggleResource(
+ request: SeedApi.ToggleRequest,
+ requestOptions?: SeedApiClient.RequestOptions,
+ ): Promise> {
+ const { key, ..._body } = request;
+ const _headers: core.Fetcher.Args["headers"] = mergeHeaders(this._options?.headers, requestOptions?.headers);
+ const _response = await core.fetcher({
+ url: core.url.join(
+ (await core.Supplier.get(this._options.baseUrl)) ??
+ (await core.Supplier.get(this._options.environment)),
+ `resources/${core.url.encodePathParam(key)}/toggle`,
+ ),
+ method: "POST",
+ headers: _headers,
+ contentType: "application/json",
+ queryParameters: requestOptions?.queryParams,
+ requestType: "json",
+ body: _body,
+ timeoutMs: (requestOptions?.timeoutInSeconds ?? this._options?.timeoutInSeconds ?? 60) * 1000,
+ maxRetries: requestOptions?.maxRetries ?? this._options?.maxRetries,
+ abortSignal: requestOptions?.abortSignal,
+ fetchFn: this._options?.fetch,
+ logging: this._options.logging,
+ });
+ if (_response.ok) {
+ return { data: _response.body as SeedApi.ToggleResponse, rawResponse: _response.rawResponse };
+ }
+
+ if (_response.error.reason === "status-code") {
+ throw new errors.SeedApiError({
+ statusCode: _response.error.statusCode,
+ body: _response.error.body,
+ rawResponse: _response.rawResponse,
+ });
+ }
+
+ return handleNonStatusCodeError(_response.error, _response.rawResponse, "POST", "/resources/{key}/toggle");
+ }
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/api/client/index.ts b/seed/ts-sdk/path-body-property-collision/src/api/client/index.ts
new file mode 100644
index 000000000000..195f9aa8a846
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/api/client/index.ts
@@ -0,0 +1 @@
+export * from "./requests/index.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/api/client/requests/ToggleRequest.ts b/seed/ts-sdk/path-body-property-collision/src/api/client/requests/ToggleRequest.ts
new file mode 100644
index 000000000000..01ac0af3377c
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/api/client/requests/ToggleRequest.ts
@@ -0,0 +1,16 @@
+// This file was auto-generated by Fern from our API Definition.
+
+/**
+ * @example
+ * {
+ * key: "key",
+ * key: "key",
+ * enabled: true
+ * }
+ */
+export interface ToggleRequest {
+ /** The resource key in the body */
+ key: string;
+ /** Whether the resource should be enabled */
+ enabled: boolean;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/api/client/requests/index.ts b/seed/ts-sdk/path-body-property-collision/src/api/client/requests/index.ts
new file mode 100644
index 000000000000..c9e0e5da0633
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/api/client/requests/index.ts
@@ -0,0 +1 @@
+export type { ToggleRequest } from "./ToggleRequest.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/api/index.ts b/seed/ts-sdk/path-body-property-collision/src/api/index.ts
new file mode 100644
index 000000000000..d9adb1af9a93
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/api/index.ts
@@ -0,0 +1,2 @@
+export * from "./client/index.js";
+export * from "./types/index.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/api/types/ToggleResponse.ts b/seed/ts-sdk/path-body-property-collision/src/api/types/ToggleResponse.ts
new file mode 100644
index 000000000000..8f40237b0cdc
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/api/types/ToggleResponse.ts
@@ -0,0 +1,5 @@
+// This file was auto-generated by Fern from our API Definition.
+
+export interface ToggleResponse {
+ success?: boolean;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/api/types/index.ts b/seed/ts-sdk/path-body-property-collision/src/api/types/index.ts
new file mode 100644
index 000000000000..be4a747232a3
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/api/types/index.ts
@@ -0,0 +1 @@
+export * from "./ToggleResponse.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/exports.ts b/seed/ts-sdk/path-body-property-collision/src/core/exports.ts
new file mode 100644
index 000000000000..69296d7100d6
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/exports.ts
@@ -0,0 +1 @@
+export * from "./logging/exports.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/APIResponse.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/APIResponse.ts
new file mode 100644
index 000000000000..97ab83c2b195
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/APIResponse.ts
@@ -0,0 +1,23 @@
+import type { RawResponse } from "./RawResponse.js";
+
+/**
+ * The response of an API call.
+ * It is a successful response or a failed response.
+ */
+export type APIResponse = SuccessfulResponse | FailedResponse;
+
+export interface SuccessfulResponse {
+ ok: true;
+ body: T;
+ /**
+ * @deprecated Use `rawResponse` instead
+ */
+ headers?: Record;
+ rawResponse: RawResponse;
+}
+
+export interface FailedResponse {
+ ok: false;
+ error: T;
+ rawResponse: RawResponse;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/BinaryResponse.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/BinaryResponse.ts
new file mode 100644
index 000000000000..bca7f4c77981
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/BinaryResponse.ts
@@ -0,0 +1,34 @@
+export type BinaryResponse = {
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */
+ bodyUsed: Response["bodyUsed"];
+ /**
+ * Returns a ReadableStream of the response body.
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body)
+ */
+ stream: () => Response["body"];
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */
+ arrayBuffer: () => ReturnType;
+ /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/blob) */
+ blob: () => ReturnType;
+ /**
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes)
+ * Some versions of the Fetch API may not support this method.
+ */
+ bytes?(): ReturnType;
+};
+
+export function getBinaryResponse(response: Response): BinaryResponse {
+ const binaryResponse: BinaryResponse = {
+ get bodyUsed() {
+ return response.bodyUsed;
+ },
+ stream: () => response.body,
+ arrayBuffer: response.arrayBuffer.bind(response),
+ blob: response.blob.bind(response),
+ };
+ if ("bytes" in response && typeof response.bytes === "function") {
+ binaryResponse.bytes = response.bytes.bind(response);
+ }
+
+ return binaryResponse;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/EndpointMetadata.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/EndpointMetadata.ts
new file mode 100644
index 000000000000..998d68f5c20c
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/EndpointMetadata.ts
@@ -0,0 +1,13 @@
+export type SecuritySchemeKey = string;
+/**
+ * A collection of security schemes, where the key is the name of the security scheme and the value is the list of scopes required for that scheme.
+ * All schemes in the collection must be satisfied for authentication to be successful.
+ */
+export type SecuritySchemeCollection = Record;
+export type AuthScope = string;
+export type EndpointMetadata = {
+ /**
+ * An array of security scheme collections. Each collection represents an alternative way to authenticate.
+ */
+ security?: SecuritySchemeCollection[];
+};
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/EndpointSupplier.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/EndpointSupplier.ts
new file mode 100644
index 000000000000..8079841c4062
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/EndpointSupplier.ts
@@ -0,0 +1,14 @@
+import type { EndpointMetadata } from "./EndpointMetadata.js";
+import type { Supplier } from "./Supplier.js";
+
+type EndpointSupplierFn = (arg: { endpointMetadata: EndpointMetadata }) => T | Promise;
+export type EndpointSupplier = Supplier | EndpointSupplierFn;
+export const EndpointSupplier = {
+ get: async (supplier: EndpointSupplier, arg: { endpointMetadata: EndpointMetadata }): Promise => {
+ if (typeof supplier === "function") {
+ return (supplier as EndpointSupplierFn)(arg);
+ } else {
+ return supplier;
+ }
+ },
+};
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/Fetcher.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/Fetcher.ts
new file mode 100644
index 000000000000..58bb0e3ef7d9
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/Fetcher.ts
@@ -0,0 +1,391 @@
+import { toJson } from "../json.js";
+import { createLogger, type LogConfig, type Logger } from "../logging/logger.js";
+import type { APIResponse } from "./APIResponse.js";
+import { createRequestUrl } from "./createRequestUrl.js";
+import type { EndpointMetadata } from "./EndpointMetadata.js";
+import { EndpointSupplier } from "./EndpointSupplier.js";
+import { getErrorResponseBody } from "./getErrorResponseBody.js";
+import { getFetchFn } from "./getFetchFn.js";
+import { getRequestBody } from "./getRequestBody.js";
+import { getResponseBody } from "./getResponseBody.js";
+import { Headers } from "./Headers.js";
+import { makeRequest } from "./makeRequest.js";
+import { abortRawResponse, toRawResponse, unknownRawResponse } from "./RawResponse.js";
+import { requestWithRetries } from "./requestWithRetries.js";
+
+export type FetchFunction = (args: Fetcher.Args) => Promise>;
+
+export declare namespace Fetcher {
+ export interface Args {
+ url: string;
+ method: string;
+ contentType?: string;
+ headers?: Record | null | undefined>;
+ queryParameters?: Record;
+ body?: unknown;
+ timeoutMs?: number;
+ maxRetries?: number;
+ withCredentials?: boolean;
+ abortSignal?: AbortSignal;
+ requestType?: "json" | "file" | "bytes" | "form" | "other";
+ responseType?: "json" | "blob" | "sse" | "streaming" | "text" | "arrayBuffer" | "binary-response";
+ duplex?: "half";
+ endpointMetadata?: EndpointMetadata;
+ fetchFn?: typeof fetch;
+ logging?: LogConfig | Logger;
+ }
+
+ export type Error = FailedStatusCodeError | NonJsonError | BodyIsNullError | TimeoutError | UnknownError;
+
+ export interface FailedStatusCodeError {
+ reason: "status-code";
+ statusCode: number;
+ body: unknown;
+ }
+
+ export interface NonJsonError {
+ reason: "non-json";
+ statusCode: number;
+ rawBody: string;
+ }
+
+ export interface BodyIsNullError {
+ reason: "body-is-null";
+ statusCode: number;
+ }
+
+ export interface TimeoutError {
+ reason: "timeout";
+ }
+
+ export interface UnknownError {
+ reason: "unknown";
+ errorMessage: string;
+ }
+}
+
+const SENSITIVE_HEADERS = new Set([
+ "authorization",
+ "www-authenticate",
+ "x-api-key",
+ "api-key",
+ "apikey",
+ "x-api-token",
+ "x-auth-token",
+ "auth-token",
+ "cookie",
+ "set-cookie",
+ "proxy-authorization",
+ "proxy-authenticate",
+ "x-csrf-token",
+ "x-xsrf-token",
+ "x-session-token",
+ "x-access-token",
+]);
+
+function redactHeaders(headers: Headers | Record): Record {
+ const filtered: Record = {};
+ for (const [key, value] of headers instanceof Headers ? headers.entries() : Object.entries(headers)) {
+ if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
+ filtered[key] = "[REDACTED]";
+ } else {
+ filtered[key] = value;
+ }
+ }
+ return filtered;
+}
+
+const SENSITIVE_QUERY_PARAMS = new Set([
+ "api_key",
+ "api-key",
+ "apikey",
+ "token",
+ "access_token",
+ "access-token",
+ "auth_token",
+ "auth-token",
+ "password",
+ "passwd",
+ "secret",
+ "api_secret",
+ "api-secret",
+ "apisecret",
+ "key",
+ "session",
+ "session_id",
+ "session-id",
+]);
+
+function redactQueryParameters(queryParameters?: Record): Record | undefined {
+ if (queryParameters == null) {
+ return queryParameters;
+ }
+ const redacted: Record = {};
+ for (const [key, value] of Object.entries(queryParameters)) {
+ if (SENSITIVE_QUERY_PARAMS.has(key.toLowerCase())) {
+ redacted[key] = "[REDACTED]";
+ } else {
+ redacted[key] = value;
+ }
+ }
+ return redacted;
+}
+
+function redactUrl(url: string): string {
+ const protocolIndex = url.indexOf("://");
+ if (protocolIndex === -1) return url;
+
+ const afterProtocol = protocolIndex + 3;
+
+ // Find the first delimiter that marks the end of the authority section
+ const pathStart = url.indexOf("/", afterProtocol);
+ let queryStart = url.indexOf("?", afterProtocol);
+ let fragmentStart = url.indexOf("#", afterProtocol);
+
+ const firstDelimiter = Math.min(
+ pathStart === -1 ? url.length : pathStart,
+ queryStart === -1 ? url.length : queryStart,
+ fragmentStart === -1 ? url.length : fragmentStart,
+ );
+
+ // Find the LAST @ before the delimiter (handles multiple @ in credentials)
+ let atIndex = -1;
+ for (let i = afterProtocol; i < firstDelimiter; i++) {
+ if (url[i] === "@") {
+ atIndex = i;
+ }
+ }
+
+ if (atIndex !== -1) {
+ url = `${url.slice(0, afterProtocol)}[REDACTED]@${url.slice(atIndex + 1)}`;
+ }
+
+ // Recalculate queryStart since url might have changed
+ queryStart = url.indexOf("?");
+ if (queryStart === -1) return url;
+
+ fragmentStart = url.indexOf("#", queryStart);
+ const queryEnd = fragmentStart !== -1 ? fragmentStart : url.length;
+ const queryString = url.slice(queryStart + 1, queryEnd);
+
+ if (queryString.length === 0) return url;
+
+ // FAST PATH: Quick check if any sensitive keywords present
+ // Using indexOf is faster than regex for simple substring matching
+ const lower = queryString.toLowerCase();
+ const hasSensitive =
+ lower.includes("token") ||
+ lower.includes("key") ||
+ lower.includes("password") ||
+ lower.includes("passwd") ||
+ lower.includes("secret") ||
+ lower.includes("session") ||
+ lower.includes("auth");
+
+ if (!hasSensitive) {
+ return url;
+ }
+
+ // SLOW PATH: Parse and redact
+ const redactedParams: string[] = [];
+ const params = queryString.split("&");
+
+ for (const param of params) {
+ const equalIndex = param.indexOf("=");
+ if (equalIndex === -1) {
+ redactedParams.push(param);
+ continue;
+ }
+
+ const key = param.slice(0, equalIndex);
+ let shouldRedact = SENSITIVE_QUERY_PARAMS.has(key.toLowerCase());
+
+ if (!shouldRedact && key.includes("%")) {
+ try {
+ const decodedKey = decodeURIComponent(key);
+ shouldRedact = SENSITIVE_QUERY_PARAMS.has(decodedKey.toLowerCase());
+ } catch {}
+ }
+
+ redactedParams.push(shouldRedact ? `${key}=[REDACTED]` : param);
+ }
+
+ return url.slice(0, queryStart + 1) + redactedParams.join("&") + url.slice(queryEnd);
+}
+
+async function getHeaders(args: Fetcher.Args): Promise {
+ const newHeaders: Headers = new Headers();
+
+ newHeaders.set(
+ "Accept",
+ args.responseType === "json" ? "application/json" : args.responseType === "text" ? "text/plain" : "*/*",
+ );
+ if (args.body !== undefined && args.contentType != null) {
+ newHeaders.set("Content-Type", args.contentType);
+ }
+
+ if (args.headers == null) {
+ return newHeaders;
+ }
+
+ for (const [key, value] of Object.entries(args.headers)) {
+ const result = await EndpointSupplier.get(value, { endpointMetadata: args.endpointMetadata ?? {} });
+ if (typeof result === "string") {
+ newHeaders.set(key, result);
+ continue;
+ }
+ if (result == null) {
+ continue;
+ }
+ newHeaders.set(key, `${result}`);
+ }
+ return newHeaders;
+}
+
+export async function fetcherImpl(args: Fetcher.Args): Promise> {
+ const url = createRequestUrl(args.url, args.queryParameters);
+ const requestBody: BodyInit | undefined = await getRequestBody({
+ body: args.body,
+ type: args.requestType ?? "other",
+ });
+ const fetchFn = args.fetchFn ?? (await getFetchFn());
+ const headers = await getHeaders(args);
+ const logger = createLogger(args.logging);
+
+ if (logger.isDebug()) {
+ const metadata = {
+ method: args.method,
+ url: redactUrl(url),
+ headers: redactHeaders(headers),
+ queryParameters: redactQueryParameters(args.queryParameters),
+ hasBody: requestBody != null,
+ };
+ logger.debug("Making HTTP request", metadata);
+ }
+
+ try {
+ const response = await requestWithRetries(
+ async () =>
+ makeRequest(
+ fetchFn,
+ url,
+ args.method,
+ headers,
+ requestBody,
+ args.timeoutMs,
+ args.abortSignal,
+ args.withCredentials,
+ args.duplex,
+ ),
+ args.maxRetries,
+ );
+
+ if (response.status >= 200 && response.status < 400) {
+ if (logger.isDebug()) {
+ const metadata = {
+ method: args.method,
+ url: redactUrl(url),
+ statusCode: response.status,
+ responseHeaders: redactHeaders(response.headers),
+ };
+ logger.debug("HTTP request succeeded", metadata);
+ }
+ const body = await getResponseBody(response, args.responseType);
+ return {
+ ok: true,
+ body: body as R,
+ headers: response.headers,
+ rawResponse: toRawResponse(response),
+ };
+ } else {
+ if (logger.isError()) {
+ const metadata = {
+ method: args.method,
+ url: redactUrl(url),
+ statusCode: response.status,
+ responseHeaders: redactHeaders(Object.fromEntries(response.headers.entries())),
+ };
+ logger.error("HTTP request failed with error status", metadata);
+ }
+ return {
+ ok: false,
+ error: {
+ reason: "status-code",
+ statusCode: response.status,
+ body: await getErrorResponseBody(response),
+ },
+ rawResponse: toRawResponse(response),
+ };
+ }
+ } catch (error) {
+ if (args.abortSignal?.aborted) {
+ if (logger.isError()) {
+ const metadata = {
+ method: args.method,
+ url: redactUrl(url),
+ };
+ logger.error("HTTP request was aborted", metadata);
+ }
+ return {
+ ok: false,
+ error: {
+ reason: "unknown",
+ errorMessage: "The user aborted a request",
+ },
+ rawResponse: abortRawResponse,
+ };
+ } else if (error instanceof Error && error.name === "AbortError") {
+ if (logger.isError()) {
+ const metadata = {
+ method: args.method,
+ url: redactUrl(url),
+ timeoutMs: args.timeoutMs,
+ };
+ logger.error("HTTP request timed out", metadata);
+ }
+ return {
+ ok: false,
+ error: {
+ reason: "timeout",
+ },
+ rawResponse: abortRawResponse,
+ };
+ } else if (error instanceof Error) {
+ if (logger.isError()) {
+ const metadata = {
+ method: args.method,
+ url: redactUrl(url),
+ errorMessage: error.message,
+ };
+ logger.error("HTTP request failed with error", metadata);
+ }
+ return {
+ ok: false,
+ error: {
+ reason: "unknown",
+ errorMessage: error.message,
+ },
+ rawResponse: unknownRawResponse,
+ };
+ }
+
+ if (logger.isError()) {
+ const metadata = {
+ method: args.method,
+ url: redactUrl(url),
+ error: toJson(error),
+ };
+ logger.error("HTTP request failed with unknown error", metadata);
+ }
+ return {
+ ok: false,
+ error: {
+ reason: "unknown",
+ errorMessage: toJson(error),
+ },
+ rawResponse: unknownRawResponse,
+ };
+ }
+}
+
+export const fetcher: FetchFunction = fetcherImpl;
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/Headers.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/Headers.ts
new file mode 100644
index 000000000000..af841aa24f55
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/Headers.ts
@@ -0,0 +1,93 @@
+let Headers: typeof globalThis.Headers;
+
+if (typeof globalThis.Headers !== "undefined") {
+ Headers = globalThis.Headers;
+} else {
+ Headers = class Headers implements Headers {
+ private headers: Map;
+
+ constructor(init?: HeadersInit) {
+ this.headers = new Map();
+
+ if (init) {
+ if (init instanceof Headers) {
+ init.forEach((value, key) => this.append(key, value));
+ } else if (Array.isArray(init)) {
+ for (const [key, value] of init) {
+ if (typeof key === "string" && typeof value === "string") {
+ this.append(key, value);
+ } else {
+ throw new TypeError("Each header entry must be a [string, string] tuple");
+ }
+ }
+ } else {
+ for (const [key, value] of Object.entries(init)) {
+ if (typeof value === "string") {
+ this.append(key, value);
+ } else {
+ throw new TypeError("Header values must be strings");
+ }
+ }
+ }
+ }
+ }
+
+ append(name: string, value: string): void {
+ const key = name.toLowerCase();
+ const existing = this.headers.get(key) || [];
+ this.headers.set(key, [...existing, value]);
+ }
+
+ delete(name: string): void {
+ const key = name.toLowerCase();
+ this.headers.delete(key);
+ }
+
+ get(name: string): string | null {
+ const key = name.toLowerCase();
+ const values = this.headers.get(key);
+ return values ? values.join(", ") : null;
+ }
+
+ has(name: string): boolean {
+ const key = name.toLowerCase();
+ return this.headers.has(key);
+ }
+
+ set(name: string, value: string): void {
+ const key = name.toLowerCase();
+ this.headers.set(key, [value]);
+ }
+
+ forEach(callbackfn: (value: string, key: string, parent: Headers) => void, thisArg?: unknown): void {
+ const boundCallback = thisArg ? callbackfn.bind(thisArg) : callbackfn;
+ this.headers.forEach((values, key) => boundCallback(values.join(", "), key, this));
+ }
+
+ getSetCookie(): string[] {
+ return this.headers.get("set-cookie") || [];
+ }
+
+ *entries(): HeadersIterator<[string, string]> {
+ for (const [key, values] of this.headers.entries()) {
+ yield [key, values.join(", ")];
+ }
+ }
+
+ *keys(): HeadersIterator {
+ yield* this.headers.keys();
+ }
+
+ *values(): HeadersIterator {
+ for (const values of this.headers.values()) {
+ yield values.join(", ");
+ }
+ }
+
+ [Symbol.iterator](): HeadersIterator<[string, string]> {
+ return this.entries();
+ }
+ };
+}
+
+export { Headers };
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/HttpResponsePromise.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/HttpResponsePromise.ts
new file mode 100644
index 000000000000..692ca7d795f0
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/HttpResponsePromise.ts
@@ -0,0 +1,116 @@
+import type { WithRawResponse } from "./RawResponse.js";
+
+/**
+ * A promise that returns the parsed response and lets you retrieve the raw response too.
+ */
+export class HttpResponsePromise extends Promise {
+ private innerPromise: Promise>;
+ private unwrappedPromise: Promise | undefined;
+
+ private constructor(promise: Promise>) {
+ // Initialize with a no-op to avoid premature parsing
+ super((resolve) => {
+ resolve(undefined as unknown as T);
+ });
+ this.innerPromise = promise;
+ }
+
+ /**
+ * Creates an `HttpResponsePromise` from a function that returns a promise.
+ *
+ * @param fn - A function that returns a promise resolving to a `WithRawResponse` object.
+ * @param args - Arguments to pass to the function.
+ * @returns An `HttpResponsePromise` instance.
+ */
+ public static fromFunction Promise>, T>(
+ fn: F,
+ ...args: Parameters
+ ): HttpResponsePromise {
+ return new HttpResponsePromise(fn(...args));
+ }
+
+ /**
+ * Creates a function that returns an `HttpResponsePromise` from a function that returns a promise.
+ *
+ * @param fn - A function that returns a promise resolving to a `WithRawResponse` object.
+ * @returns A function that returns an `HttpResponsePromise` instance.
+ */
+ public static interceptFunction<
+ F extends (...args: never[]) => Promise>,
+ T = Awaited>["data"],
+ >(fn: F): (...args: Parameters) => HttpResponsePromise {
+ return (...args: Parameters): HttpResponsePromise => {
+ return HttpResponsePromise.fromPromise(fn(...args));
+ };
+ }
+
+ /**
+ * Creates an `HttpResponsePromise` from an existing promise.
+ *
+ * @param promise - A promise resolving to a `WithRawResponse` object.
+ * @returns An `HttpResponsePromise` instance.
+ */
+ public static fromPromise(promise: Promise>): HttpResponsePromise {
+ return new HttpResponsePromise(promise);
+ }
+
+ /**
+ * Creates an `HttpResponsePromise` from an executor function.
+ *
+ * @param executor - A function that takes resolve and reject callbacks to create a promise.
+ * @returns An `HttpResponsePromise` instance.
+ */
+ public static fromExecutor(
+ executor: (resolve: (value: WithRawResponse) => void, reject: (reason?: unknown) => void) => void,
+ ): HttpResponsePromise {
+ const promise = new Promise>(executor);
+ return new HttpResponsePromise(promise);
+ }
+
+ /**
+ * Creates an `HttpResponsePromise` from a resolved result.
+ *
+ * @param result - A `WithRawResponse` object to resolve immediately.
+ * @returns An `HttpResponsePromise` instance.
+ */
+ public static fromResult(result: WithRawResponse): HttpResponsePromise {
+ const promise = Promise.resolve(result);
+ return new HttpResponsePromise(promise);
+ }
+
+ private unwrap(): Promise {
+ if (!this.unwrappedPromise) {
+ this.unwrappedPromise = this.innerPromise.then(({ data }) => data);
+ }
+ return this.unwrappedPromise;
+ }
+
+ /** @inheritdoc */
+ public override then(
+ onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null,
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null,
+ ): Promise {
+ return this.unwrap().then(onfulfilled, onrejected);
+ }
+
+ /** @inheritdoc */
+ public override catch(
+ onrejected?: ((reason: unknown) => TResult | PromiseLike) | null,
+ ): Promise {
+ return this.unwrap().catch(onrejected);
+ }
+
+ /** @inheritdoc */
+ public override finally(onfinally?: (() => void) | null): Promise {
+ return this.unwrap().finally(onfinally);
+ }
+
+ /**
+ * Retrieves the data and raw response.
+ *
+ * @returns A promise resolving to a `WithRawResponse` object.
+ */
+ public async withRawResponse(): Promise> {
+ return await this.innerPromise;
+ }
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/RawResponse.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/RawResponse.ts
new file mode 100644
index 000000000000..37fb44e2aa99
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/RawResponse.ts
@@ -0,0 +1,61 @@
+import { Headers } from "./Headers.js";
+
+/**
+ * The raw response from the fetch call excluding the body.
+ */
+export type RawResponse = Omit<
+ {
+ [K in keyof Response as Response[K] extends Function ? never : K]: Response[K]; // strips out functions
+ },
+ "ok" | "body" | "bodyUsed"
+>; // strips out body and bodyUsed
+
+/**
+ * A raw response indicating that the request was aborted.
+ */
+export const abortRawResponse: RawResponse = {
+ headers: new Headers(),
+ redirected: false,
+ status: 499,
+ statusText: "Client Closed Request",
+ type: "error",
+ url: "",
+} as const;
+
+/**
+ * A raw response indicating an unknown error.
+ */
+export const unknownRawResponse: RawResponse = {
+ headers: new Headers(),
+ redirected: false,
+ status: 0,
+ statusText: "Unknown Error",
+ type: "error",
+ url: "",
+} as const;
+
+/**
+ * Converts a `RawResponse` object into a `RawResponse` by extracting its properties,
+ * excluding the `body` and `bodyUsed` fields.
+ *
+ * @param response - The `RawResponse` object to convert.
+ * @returns A `RawResponse` object containing the extracted properties of the input response.
+ */
+export function toRawResponse(response: Response): RawResponse {
+ return {
+ headers: response.headers,
+ redirected: response.redirected,
+ status: response.status,
+ statusText: response.statusText,
+ type: response.type,
+ url: response.url,
+ };
+}
+
+/**
+ * Creates a `RawResponse` from a standard `Response` object.
+ */
+export interface WithRawResponse {
+ readonly data: T;
+ readonly rawResponse: RawResponse;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/Supplier.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/Supplier.ts
new file mode 100644
index 000000000000..867c931c02f4
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/Supplier.ts
@@ -0,0 +1,11 @@
+export type Supplier = T | Promise | (() => T | Promise);
+
+export const Supplier = {
+ get: async (supplier: Supplier): Promise => {
+ if (typeof supplier === "function") {
+ return (supplier as () => T)();
+ } else {
+ return supplier;
+ }
+ },
+};
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/createRequestUrl.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/createRequestUrl.ts
new file mode 100644
index 000000000000..88e13265e112
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/createRequestUrl.ts
@@ -0,0 +1,6 @@
+import { toQueryString } from "../url/qs.js";
+
+export function createRequestUrl(baseUrl: string, queryParameters?: Record): string {
+ const queryString = toQueryString(queryParameters, { arrayFormat: "repeat" });
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getErrorResponseBody.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getErrorResponseBody.ts
new file mode 100644
index 000000000000..7cf4e623c2f5
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getErrorResponseBody.ts
@@ -0,0 +1,33 @@
+import { fromJson } from "../json.js";
+import { getResponseBody } from "./getResponseBody.js";
+
+export async function getErrorResponseBody(response: Response): Promise {
+ let contentType = response.headers.get("Content-Type")?.toLowerCase();
+ if (contentType == null || contentType.length === 0) {
+ return getResponseBody(response);
+ }
+
+ if (contentType.indexOf(";") !== -1) {
+ contentType = contentType.split(";")[0]?.trim() ?? "";
+ }
+ switch (contentType) {
+ case "application/hal+json":
+ case "application/json":
+ case "application/ld+json":
+ case "application/problem+json":
+ case "application/vnd.api+json":
+ case "text/json": {
+ const text = await response.text();
+ return text.length > 0 ? fromJson(text) : undefined;
+ }
+ default:
+ if (contentType.startsWith("application/vnd.") && contentType.endsWith("+json")) {
+ const text = await response.text();
+ return text.length > 0 ? fromJson(text) : undefined;
+ }
+
+ // Fallback to plain text if content type is not recognized
+ // Even if no body is present, the response will be an empty string
+ return await response.text();
+ }
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getFetchFn.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getFetchFn.ts
new file mode 100644
index 000000000000..9f845b956392
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getFetchFn.ts
@@ -0,0 +1,3 @@
+export async function getFetchFn(): Promise {
+ return fetch;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getHeader.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getHeader.ts
new file mode 100644
index 000000000000..50f922b0e87f
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getHeader.ts
@@ -0,0 +1,8 @@
+export function getHeader(headers: Record, header: string): string | undefined {
+ for (const [headerKey, headerValue] of Object.entries(headers)) {
+ if (headerKey.toLowerCase() === header.toLowerCase()) {
+ return headerValue;
+ }
+ }
+ return undefined;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getRequestBody.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getRequestBody.ts
new file mode 100644
index 000000000000..91d9d81f50e5
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getRequestBody.ts
@@ -0,0 +1,20 @@
+import { toJson } from "../json.js";
+import { toQueryString } from "../url/qs.js";
+
+export declare namespace GetRequestBody {
+ interface Args {
+ body: unknown;
+ type: "json" | "file" | "bytes" | "form" | "other";
+ }
+}
+
+export async function getRequestBody({ body, type }: GetRequestBody.Args): Promise {
+ if (type === "form") {
+ return toQueryString(body, { arrayFormat: "repeat", encode: true });
+ }
+ if (type.includes("json")) {
+ return toJson(body);
+ } else {
+ return body as BodyInit;
+ }
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getResponseBody.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getResponseBody.ts
new file mode 100644
index 000000000000..708d55728f2b
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/getResponseBody.ts
@@ -0,0 +1,58 @@
+import { fromJson } from "../json.js";
+import { getBinaryResponse } from "./BinaryResponse.js";
+
+export async function getResponseBody(response: Response, responseType?: string): Promise {
+ switch (responseType) {
+ case "binary-response":
+ return getBinaryResponse(response);
+ case "blob":
+ return await response.blob();
+ case "arrayBuffer":
+ return await response.arrayBuffer();
+ case "sse":
+ if (response.body == null) {
+ return {
+ ok: false,
+ error: {
+ reason: "body-is-null",
+ statusCode: response.status,
+ },
+ };
+ }
+ return response.body;
+ case "streaming":
+ if (response.body == null) {
+ return {
+ ok: false,
+ error: {
+ reason: "body-is-null",
+ statusCode: response.status,
+ },
+ };
+ }
+
+ return response.body;
+
+ case "text":
+ return await response.text();
+ }
+
+ // if responseType is "json" or not specified, try to parse as JSON
+ const text = await response.text();
+ if (text.length > 0) {
+ try {
+ const responseBody = fromJson(text);
+ return responseBody;
+ } catch (_err) {
+ return {
+ ok: false,
+ error: {
+ reason: "non-json",
+ statusCode: response.status,
+ rawBody: text,
+ },
+ };
+ }
+ }
+ return undefined;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/index.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/index.ts
new file mode 100644
index 000000000000..c3bc6da20f49
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/index.ts
@@ -0,0 +1,11 @@
+export type { APIResponse } from "./APIResponse.js";
+export type { BinaryResponse } from "./BinaryResponse.js";
+export type { EndpointMetadata } from "./EndpointMetadata.js";
+export { EndpointSupplier } from "./EndpointSupplier.js";
+export type { Fetcher, FetchFunction } from "./Fetcher.js";
+export { fetcher } from "./Fetcher.js";
+export { getHeader } from "./getHeader.js";
+export { HttpResponsePromise } from "./HttpResponsePromise.js";
+export type { RawResponse, WithRawResponse } from "./RawResponse.js";
+export { abortRawResponse, toRawResponse, unknownRawResponse } from "./RawResponse.js";
+export { Supplier } from "./Supplier.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/makeRequest.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/makeRequest.ts
new file mode 100644
index 000000000000..921565eb0063
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/makeRequest.ts
@@ -0,0 +1,42 @@
+import { anySignal, getTimeoutSignal } from "./signals.js";
+
+export const makeRequest = async (
+ fetchFn: (url: string, init: RequestInit) => Promise,
+ url: string,
+ method: string,
+ headers: Headers | Record,
+ requestBody: BodyInit | undefined,
+ timeoutMs?: number,
+ abortSignal?: AbortSignal,
+ withCredentials?: boolean,
+ duplex?: "half",
+): Promise => {
+ const signals: AbortSignal[] = [];
+
+ let timeoutAbortId: ReturnType | undefined;
+ if (timeoutMs != null) {
+ const { signal, abortId } = getTimeoutSignal(timeoutMs);
+ timeoutAbortId = abortId;
+ signals.push(signal);
+ }
+
+ if (abortSignal != null) {
+ signals.push(abortSignal);
+ }
+ const newSignals = anySignal(signals);
+ const response = await fetchFn(url, {
+ method: method,
+ headers,
+ body: requestBody,
+ signal: newSignals,
+ credentials: withCredentials ? "include" : undefined,
+ // @ts-ignore
+ duplex,
+ });
+
+ if (timeoutAbortId != null) {
+ clearTimeout(timeoutAbortId);
+ }
+
+ return response;
+};
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/requestWithRetries.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/requestWithRetries.ts
new file mode 100644
index 000000000000..1f689688c4b2
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/requestWithRetries.ts
@@ -0,0 +1,64 @@
+const INITIAL_RETRY_DELAY = 1000; // in milliseconds
+const MAX_RETRY_DELAY = 60000; // in milliseconds
+const DEFAULT_MAX_RETRIES = 2;
+const JITTER_FACTOR = 0.2; // 20% random jitter
+
+function addPositiveJitter(delay: number): number {
+ const jitterMultiplier = 1 + Math.random() * JITTER_FACTOR;
+ return delay * jitterMultiplier;
+}
+
+function addSymmetricJitter(delay: number): number {
+ const jitterMultiplier = 1 + (Math.random() - 0.5) * JITTER_FACTOR;
+ return delay * jitterMultiplier;
+}
+
+function getRetryDelayFromHeaders(response: Response, retryAttempt: number): number {
+ const retryAfter = response.headers.get("Retry-After");
+ if (retryAfter) {
+ const retryAfterSeconds = parseInt(retryAfter, 10);
+ if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds > 0) {
+ return Math.min(retryAfterSeconds * 1000, MAX_RETRY_DELAY);
+ }
+
+ const retryAfterDate = new Date(retryAfter);
+ if (!Number.isNaN(retryAfterDate.getTime())) {
+ const delay = retryAfterDate.getTime() - Date.now();
+ if (delay > 0) {
+ return Math.min(Math.max(delay, 0), MAX_RETRY_DELAY);
+ }
+ }
+ }
+
+ const rateLimitReset = response.headers.get("X-RateLimit-Reset");
+ if (rateLimitReset) {
+ const resetTime = parseInt(rateLimitReset, 10);
+ if (!Number.isNaN(resetTime)) {
+ const delay = resetTime * 1000 - Date.now();
+ if (delay > 0) {
+ return addPositiveJitter(Math.min(delay, MAX_RETRY_DELAY));
+ }
+ }
+ }
+
+ return addSymmetricJitter(Math.min(INITIAL_RETRY_DELAY * 2 ** retryAttempt, MAX_RETRY_DELAY));
+}
+
+export async function requestWithRetries(
+ requestFn: () => Promise,
+ maxRetries: number = DEFAULT_MAX_RETRIES,
+): Promise {
+ let response: Response = await requestFn();
+
+ for (let i = 0; i < maxRetries; ++i) {
+ if ([408, 429].includes(response.status) || response.status >= 500) {
+ const delay = getRetryDelayFromHeaders(response, i);
+
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ response = await requestFn();
+ } else {
+ break;
+ }
+ }
+ return response!;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/fetcher/signals.ts b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/signals.ts
new file mode 100644
index 000000000000..7bd3757ec3a7
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/fetcher/signals.ts
@@ -0,0 +1,26 @@
+const TIMEOUT = "timeout";
+
+export function getTimeoutSignal(timeoutMs: number): { signal: AbortSignal; abortId: ReturnType } {
+ const controller = new AbortController();
+ const abortId = setTimeout(() => controller.abort(TIMEOUT), timeoutMs);
+ return { signal: controller.signal, abortId };
+}
+
+export function anySignal(...args: AbortSignal[] | [AbortSignal[]]): AbortSignal {
+ const signals = (args.length === 1 && Array.isArray(args[0]) ? args[0] : args) as AbortSignal[];
+
+ const controller = new AbortController();
+
+ for (const signal of signals) {
+ if (signal.aborted) {
+ controller.abort((signal as any)?.reason);
+ break;
+ }
+
+ signal.addEventListener("abort", () => controller.abort((signal as any)?.reason), {
+ signal: controller.signal,
+ });
+ }
+
+ return controller.signal;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/headers.ts b/seed/ts-sdk/path-body-property-collision/src/core/headers.ts
new file mode 100644
index 000000000000..78ed8b500c95
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/headers.ts
@@ -0,0 +1,35 @@
+export function mergeHeaders(
+ ...headersArray: (Record | null | undefined)[]
+): Record {
+ const result: Record = {};
+
+ for (const [key, value] of headersArray
+ .filter((headers) => headers != null)
+ .flatMap((headers) => Object.entries(headers))) {
+ const insensitiveKey = key.toLowerCase();
+ if (value != null) {
+ result[insensitiveKey] = value;
+ } else if (insensitiveKey in result) {
+ delete result[insensitiveKey];
+ }
+ }
+
+ return result;
+}
+
+export function mergeOnlyDefinedHeaders(
+ ...headersArray: (Record | null | undefined)[]
+): Record {
+ const result: Record = {};
+
+ for (const [key, value] of headersArray
+ .filter((headers) => headers != null)
+ .flatMap((headers) => Object.entries(headers))) {
+ const insensitiveKey = key.toLowerCase();
+ if (value != null) {
+ result[insensitiveKey] = value;
+ }
+ }
+
+ return result;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/index.ts b/seed/ts-sdk/path-body-property-collision/src/core/index.ts
new file mode 100644
index 000000000000..afa8351fcf85
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/index.ts
@@ -0,0 +1,4 @@
+export * from "./fetcher/index.js";
+export * as logging from "./logging/index.js";
+export * from "./runtime/index.js";
+export * as url from "./url/index.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/json.ts b/seed/ts-sdk/path-body-property-collision/src/core/json.ts
new file mode 100644
index 000000000000..c052f3249f4f
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/json.ts
@@ -0,0 +1,27 @@
+/**
+ * Serialize a value to JSON
+ * @param value A JavaScript value, usually an object or array, to be converted.
+ * @param replacer A function that transforms the results.
+ * @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
+ * @returns JSON string
+ */
+export const toJson = (
+ value: unknown,
+ replacer?: (this: unknown, key: string, value: unknown) => unknown,
+ space?: string | number,
+): string => {
+ return JSON.stringify(value, replacer, space);
+};
+
+/**
+ * Parse JSON string to object, array, or other type
+ * @param text A valid JSON string.
+ * @param reviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is.
+ * @returns Parsed object, array, or other type
+ */
+export function fromJson(
+ text: string,
+ reviver?: (this: unknown, key: string, value: unknown) => unknown,
+): T {
+ return JSON.parse(text, reviver);
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/logging/exports.ts b/seed/ts-sdk/path-body-property-collision/src/core/logging/exports.ts
new file mode 100644
index 000000000000..88f6c00db0cf
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/logging/exports.ts
@@ -0,0 +1,19 @@
+import * as logger from "./logger.js";
+
+export namespace logging {
+ /**
+ * Configuration for logger instances.
+ */
+ export type LogConfig = logger.LogConfig;
+ export type LogLevel = logger.LogLevel;
+ export const LogLevel: typeof logger.LogLevel = logger.LogLevel;
+ export type ILogger = logger.ILogger;
+ /**
+ * Console logger implementation that outputs to the console.
+ */
+ export type ConsoleLogger = logger.ConsoleLogger;
+ /**
+ * Console logger implementation that outputs to the console.
+ */
+ export const ConsoleLogger: typeof logger.ConsoleLogger = logger.ConsoleLogger;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/logging/index.ts b/seed/ts-sdk/path-body-property-collision/src/core/logging/index.ts
new file mode 100644
index 000000000000..d81cc32c40f9
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/logging/index.ts
@@ -0,0 +1 @@
+export * from "./logger.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/logging/logger.ts b/seed/ts-sdk/path-body-property-collision/src/core/logging/logger.ts
new file mode 100644
index 000000000000..a3f3673cda93
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/logging/logger.ts
@@ -0,0 +1,203 @@
+export const LogLevel = {
+ Debug: "debug",
+ Info: "info",
+ Warn: "warn",
+ Error: "error",
+} as const;
+export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];
+const logLevelMap: Record = {
+ [LogLevel.Debug]: 1,
+ [LogLevel.Info]: 2,
+ [LogLevel.Warn]: 3,
+ [LogLevel.Error]: 4,
+};
+
+export interface ILogger {
+ /**
+ * Logs a debug message.
+ * @param message - The message to log
+ * @param args - Additional arguments to log
+ */
+ debug(message: string, ...args: unknown[]): void;
+ /**
+ * Logs an info message.
+ * @param message - The message to log
+ * @param args - Additional arguments to log
+ */
+ info(message: string, ...args: unknown[]): void;
+ /**
+ * Logs a warning message.
+ * @param message - The message to log
+ * @param args - Additional arguments to log
+ */
+ warn(message: string, ...args: unknown[]): void;
+ /**
+ * Logs an error message.
+ * @param message - The message to log
+ * @param args - Additional arguments to log
+ */
+ error(message: string, ...args: unknown[]): void;
+}
+
+/**
+ * Configuration for logger initialization.
+ */
+export interface LogConfig {
+ /**
+ * Minimum log level to output.
+ * @default LogLevel.Info
+ */
+ level?: LogLevel;
+ /**
+ * Logger implementation to use.
+ * @default new ConsoleLogger()
+ */
+ logger?: ILogger;
+ /**
+ * Whether logging should be silenced.
+ * @default true
+ */
+ silent?: boolean;
+}
+
+/**
+ * Default console-based logger implementation.
+ */
+export class ConsoleLogger implements ILogger {
+ debug(message: string, ...args: unknown[]): void {
+ console.debug(message, ...args);
+ }
+ info(message: string, ...args: unknown[]): void {
+ console.info(message, ...args);
+ }
+ warn(message: string, ...args: unknown[]): void {
+ console.warn(message, ...args);
+ }
+ error(message: string, ...args: unknown[]): void {
+ console.error(message, ...args);
+ }
+}
+
+/**
+ * Logger class that provides level-based logging functionality.
+ */
+export class Logger {
+ private readonly level: number;
+ private readonly logger: ILogger;
+ private readonly silent: boolean;
+
+ /**
+ * Creates a new logger instance.
+ * @param config - Logger configuration
+ */
+ constructor(config: Required) {
+ this.level = logLevelMap[config.level];
+ this.logger = config.logger;
+ this.silent = config.silent;
+ }
+
+ /**
+ * Checks if a log level should be output based on configuration.
+ * @param level - The log level to check
+ * @returns True if the level should be logged
+ */
+ public shouldLog(level: LogLevel): boolean {
+ return !this.silent && this.level <= logLevelMap[level];
+ }
+
+ /**
+ * Checks if debug logging is enabled.
+ * @returns True if debug logs should be output
+ */
+ public isDebug(): boolean {
+ return this.shouldLog(LogLevel.Debug);
+ }
+
+ /**
+ * Logs a debug message if debug logging is enabled.
+ * @param message - The message to log
+ * @param args - Additional arguments to log
+ */
+ public debug(message: string, ...args: unknown[]): void {
+ if (this.isDebug()) {
+ this.logger.debug(message, ...args);
+ }
+ }
+
+ /**
+ * Checks if info logging is enabled.
+ * @returns True if info logs should be output
+ */
+ public isInfo(): boolean {
+ return this.shouldLog(LogLevel.Info);
+ }
+
+ /**
+ * Logs an info message if info logging is enabled.
+ * @param message - The message to log
+ * @param args - Additional arguments to log
+ */
+ public info(message: string, ...args: unknown[]): void {
+ if (this.isInfo()) {
+ this.logger.info(message, ...args);
+ }
+ }
+
+ /**
+ * Checks if warning logging is enabled.
+ * @returns True if warning logs should be output
+ */
+ public isWarn(): boolean {
+ return this.shouldLog(LogLevel.Warn);
+ }
+
+ /**
+ * Logs a warning message if warning logging is enabled.
+ * @param message - The message to log
+ * @param args - Additional arguments to log
+ */
+ public warn(message: string, ...args: unknown[]): void {
+ if (this.isWarn()) {
+ this.logger.warn(message, ...args);
+ }
+ }
+
+ /**
+ * Checks if error logging is enabled.
+ * @returns True if error logs should be output
+ */
+ public isError(): boolean {
+ return this.shouldLog(LogLevel.Error);
+ }
+
+ /**
+ * Logs an error message if error logging is enabled.
+ * @param message - The message to log
+ * @param args - Additional arguments to log
+ */
+ public error(message: string, ...args: unknown[]): void {
+ if (this.isError()) {
+ this.logger.error(message, ...args);
+ }
+ }
+}
+
+export function createLogger(config?: LogConfig | Logger): Logger {
+ if (config == null) {
+ return defaultLogger;
+ }
+ if (config instanceof Logger) {
+ return config;
+ }
+ config = config ?? {};
+ config.level ??= LogLevel.Info;
+ config.logger ??= new ConsoleLogger();
+ config.silent ??= true;
+ return new Logger(config as Required);
+}
+
+const defaultLogger: Logger = new Logger({
+ level: LogLevel.Info,
+ logger: new ConsoleLogger(),
+ silent: true,
+});
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/runtime/index.ts b/seed/ts-sdk/path-body-property-collision/src/core/runtime/index.ts
new file mode 100644
index 000000000000..cfab23f9a834
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/runtime/index.ts
@@ -0,0 +1 @@
+export { RUNTIME } from "./runtime.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/runtime/runtime.ts b/seed/ts-sdk/path-body-property-collision/src/core/runtime/runtime.ts
new file mode 100644
index 000000000000..56ebbb87c4d3
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/runtime/runtime.ts
@@ -0,0 +1,134 @@
+interface DenoGlobal {
+ version: {
+ deno: string;
+ };
+}
+
+interface BunGlobal {
+ version: string;
+}
+
+declare const Deno: DenoGlobal | undefined;
+declare const Bun: BunGlobal | undefined;
+declare const EdgeRuntime: string | undefined;
+declare const self: typeof globalThis.self & {
+ importScripts?: unknown;
+};
+
+/**
+ * A constant that indicates which environment and version the SDK is running in.
+ */
+export const RUNTIME: Runtime = evaluateRuntime();
+
+export interface Runtime {
+ type: "browser" | "web-worker" | "deno" | "bun" | "node" | "react-native" | "unknown" | "workerd" | "edge-runtime";
+ version?: string;
+ parsedVersion?: number;
+}
+
+function evaluateRuntime(): Runtime {
+ /**
+ * A constant that indicates whether the environment the code is running is a Web Browser.
+ */
+ const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined";
+ if (isBrowser) {
+ return {
+ type: "browser",
+ version: window.navigator.userAgent,
+ };
+ }
+
+ /**
+ * A constant that indicates whether the environment the code is running is Cloudflare.
+ * https://developers.cloudflare.com/workers/runtime-apis/web-standards/#navigatoruseragent
+ */
+ const isCloudflare = typeof globalThis !== "undefined" && globalThis?.navigator?.userAgent === "Cloudflare-Workers";
+ if (isCloudflare) {
+ return {
+ type: "workerd",
+ };
+ }
+
+ /**
+ * A constant that indicates whether the environment the code is running is Edge Runtime.
+ * https://vercel.com/docs/functions/runtimes/edge-runtime#check-if-you're-running-on-the-edge-runtime
+ */
+ const isEdgeRuntime = typeof EdgeRuntime === "string";
+ if (isEdgeRuntime) {
+ return {
+ type: "edge-runtime",
+ };
+ }
+
+ /**
+ * A constant that indicates whether the environment the code is running is a Web Worker.
+ */
+ const isWebWorker =
+ typeof self === "object" &&
+ typeof self?.importScripts === "function" &&
+ (self.constructor?.name === "DedicatedWorkerGlobalScope" ||
+ self.constructor?.name === "ServiceWorkerGlobalScope" ||
+ self.constructor?.name === "SharedWorkerGlobalScope");
+ if (isWebWorker) {
+ return {
+ type: "web-worker",
+ };
+ }
+
+ /**
+ * A constant that indicates whether the environment the code is running is Deno.
+ * FYI Deno spoofs process.versions.node, see https://deno.land/std@0.177.0/node/process.ts?s=versions
+ */
+ const isDeno =
+ typeof Deno !== "undefined" && typeof Deno.version !== "undefined" && typeof Deno.version.deno !== "undefined";
+ if (isDeno) {
+ return {
+ type: "deno",
+ version: Deno.version.deno,
+ };
+ }
+
+ /**
+ * A constant that indicates whether the environment the code is running is Bun.sh.
+ */
+ const isBun = typeof Bun !== "undefined" && typeof Bun.version !== "undefined";
+ if (isBun) {
+ return {
+ type: "bun",
+ version: Bun.version,
+ };
+ }
+
+ /**
+ * A constant that indicates whether the environment the code is running is in React-Native.
+ * This check should come before Node.js detection since React Native may have a process polyfill.
+ * https://github.com/facebook/react-native/blob/main/packages/react-native/Libraries/Core/setUpNavigator.js
+ */
+ const isReactNative = typeof navigator !== "undefined" && navigator?.product === "ReactNative";
+ if (isReactNative) {
+ return {
+ type: "react-native",
+ };
+ }
+
+ /**
+ * A constant that indicates whether the environment the code is running is Node.JS.
+ */
+ const isNode =
+ typeof process !== "undefined" &&
+ "version" in process &&
+ !!process.version &&
+ "versions" in process &&
+ !!process.versions?.node;
+ if (isNode) {
+ return {
+ type: "node",
+ version: process.versions.node,
+ parsedVersion: Number(process.versions.node.split(".")[0]),
+ };
+ }
+
+ return {
+ type: "unknown",
+ };
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/url/encodePathParam.ts b/seed/ts-sdk/path-body-property-collision/src/core/url/encodePathParam.ts
new file mode 100644
index 000000000000..19b901244218
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/url/encodePathParam.ts
@@ -0,0 +1,18 @@
+export function encodePathParam(param: unknown): string {
+ if (param === null) {
+ return "null";
+ }
+ const typeofParam = typeof param;
+ switch (typeofParam) {
+ case "undefined":
+ return "undefined";
+ case "string":
+ case "number":
+ case "boolean":
+ break;
+ default:
+ param = String(param);
+ break;
+ }
+ return encodeURIComponent(param as string | number | boolean);
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/url/index.ts b/seed/ts-sdk/path-body-property-collision/src/core/url/index.ts
new file mode 100644
index 000000000000..f2e0fa2d2221
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/url/index.ts
@@ -0,0 +1,3 @@
+export { encodePathParam } from "./encodePathParam.js";
+export { join } from "./join.js";
+export { toQueryString } from "./qs.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/url/join.ts b/seed/ts-sdk/path-body-property-collision/src/core/url/join.ts
new file mode 100644
index 000000000000..7ca7daef094d
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/url/join.ts
@@ -0,0 +1,79 @@
+export function join(base: string, ...segments: string[]): string {
+ if (!base) {
+ return "";
+ }
+
+ if (segments.length === 0) {
+ return base;
+ }
+
+ if (base.includes("://")) {
+ let url: URL;
+ try {
+ url = new URL(base);
+ } catch {
+ return joinPath(base, ...segments);
+ }
+
+ const lastSegment = segments[segments.length - 1];
+ const shouldPreserveTrailingSlash = lastSegment?.endsWith("/");
+
+ for (const segment of segments) {
+ const cleanSegment = trimSlashes(segment);
+ if (cleanSegment) {
+ url.pathname = joinPathSegments(url.pathname, cleanSegment);
+ }
+ }
+
+ if (shouldPreserveTrailingSlash && !url.pathname.endsWith("/")) {
+ url.pathname += "/";
+ }
+
+ return url.toString();
+ }
+
+ return joinPath(base, ...segments);
+}
+
+function joinPath(base: string, ...segments: string[]): string {
+ if (segments.length === 0) {
+ return base;
+ }
+
+ let result = base;
+
+ const lastSegment = segments[segments.length - 1];
+ const shouldPreserveTrailingSlash = lastSegment?.endsWith("/");
+
+ for (const segment of segments) {
+ const cleanSegment = trimSlashes(segment);
+ if (cleanSegment) {
+ result = joinPathSegments(result, cleanSegment);
+ }
+ }
+
+ if (shouldPreserveTrailingSlash && !result.endsWith("/")) {
+ result += "/";
+ }
+
+ return result;
+}
+
+function joinPathSegments(left: string, right: string): string {
+ if (left.endsWith("/")) {
+ return left + right;
+ }
+ return `${left}/${right}`;
+}
+
+function trimSlashes(str: string): string {
+ if (!str) return str;
+
+ let start = 0;
+ let end = str.length;
+
+ if (str.startsWith("/")) start = 1;
+ if (str.endsWith("/")) end = str.length - 1;
+
+ return start === 0 && end === str.length ? str : str.slice(start, end);
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/core/url/qs.ts b/seed/ts-sdk/path-body-property-collision/src/core/url/qs.ts
new file mode 100644
index 000000000000..13e89be9d9a6
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/core/url/qs.ts
@@ -0,0 +1,74 @@
+interface QueryStringOptions {
+ arrayFormat?: "indices" | "repeat";
+ encode?: boolean;
+}
+
+const defaultQsOptions: Required = {
+ arrayFormat: "indices",
+ encode: true,
+} as const;
+
+function encodeValue(value: unknown, shouldEncode: boolean): string {
+ if (value === undefined) {
+ return "";
+ }
+ if (value === null) {
+ return "";
+ }
+ const stringValue = String(value);
+ return shouldEncode ? encodeURIComponent(stringValue) : stringValue;
+}
+
+function stringifyObject(obj: Record, prefix = "", options: Required): string[] {
+ const parts: string[] = [];
+
+ for (const [key, value] of Object.entries(obj)) {
+ const fullKey = prefix ? `${prefix}[${key}]` : key;
+
+ if (value === undefined) {
+ continue;
+ }
+
+ if (Array.isArray(value)) {
+ if (value.length === 0) {
+ continue;
+ }
+ for (let i = 0; i < value.length; i++) {
+ const item = value[i];
+ if (item === undefined) {
+ continue;
+ }
+ if (typeof item === "object" && !Array.isArray(item) && item !== null) {
+ const arrayKey = options.arrayFormat === "indices" ? `${fullKey}[${i}]` : fullKey;
+ parts.push(...stringifyObject(item as Record, arrayKey, options));
+ } else {
+ const arrayKey = options.arrayFormat === "indices" ? `${fullKey}[${i}]` : fullKey;
+ const encodedKey = options.encode ? encodeURIComponent(arrayKey) : arrayKey;
+ parts.push(`${encodedKey}=${encodeValue(item, options.encode)}`);
+ }
+ }
+ } else if (typeof value === "object" && value !== null) {
+ if (Object.keys(value as Record).length === 0) {
+ continue;
+ }
+ parts.push(...stringifyObject(value as Record, fullKey, options));
+ } else {
+ const encodedKey = options.encode ? encodeURIComponent(fullKey) : fullKey;
+ parts.push(`${encodedKey}=${encodeValue(value, options.encode)}`);
+ }
+ }
+
+ return parts;
+}
+
+export function toQueryString(obj: unknown, options?: QueryStringOptions): string {
+ if (obj == null || typeof obj !== "object") {
+ return "";
+ }
+
+ const parts = stringifyObject(obj as Record, "", {
+ ...defaultQsOptions,
+ ...options,
+ });
+ return parts.join("&");
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/errors/SeedApiError.ts b/seed/ts-sdk/path-body-property-collision/src/errors/SeedApiError.ts
new file mode 100644
index 000000000000..feb7d3461003
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/errors/SeedApiError.ts
@@ -0,0 +1,58 @@
+// This file was auto-generated by Fern from our API Definition.
+
+import type * as core from "../core/index.js";
+import { toJson } from "../core/json.js";
+
+export class SeedApiError extends Error {
+ public readonly statusCode?: number;
+ public readonly body?: unknown;
+ public readonly rawResponse?: core.RawResponse;
+
+ constructor({
+ message,
+ statusCode,
+ body,
+ rawResponse,
+ }: {
+ message?: string;
+ statusCode?: number;
+ body?: unknown;
+ rawResponse?: core.RawResponse;
+ }) {
+ super(buildMessage({ message, statusCode, body }));
+ Object.setPrototypeOf(this, new.target.prototype);
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, this.constructor);
+ }
+
+ this.name = this.constructor.name;
+ this.statusCode = statusCode;
+ this.body = body;
+ this.rawResponse = rawResponse;
+ }
+}
+
+function buildMessage({
+ message,
+ statusCode,
+ body,
+}: {
+ message: string | undefined;
+ statusCode: number | undefined;
+ body: unknown | undefined;
+}): string {
+ const lines: string[] = [];
+ if (message != null) {
+ lines.push(message);
+ }
+
+ if (statusCode != null) {
+ lines.push(`Status code: ${statusCode.toString()}`);
+ }
+
+ if (body != null) {
+ lines.push(`Body: ${toJson(body, undefined, 2)}`);
+ }
+
+ return lines.join("\n");
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/errors/SeedApiTimeoutError.ts b/seed/ts-sdk/path-body-property-collision/src/errors/SeedApiTimeoutError.ts
new file mode 100644
index 000000000000..5f451edf37bb
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/errors/SeedApiTimeoutError.ts
@@ -0,0 +1,13 @@
+// This file was auto-generated by Fern from our API Definition.
+
+export class SeedApiTimeoutError extends Error {
+ constructor(message: string) {
+ super(message);
+ Object.setPrototypeOf(this, new.target.prototype);
+ if (Error.captureStackTrace) {
+ Error.captureStackTrace(this, this.constructor);
+ }
+
+ this.name = this.constructor.name;
+ }
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/errors/handleNonStatusCodeError.ts b/seed/ts-sdk/path-body-property-collision/src/errors/handleNonStatusCodeError.ts
new file mode 100644
index 000000000000..fdb7a48879da
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/errors/handleNonStatusCodeError.ts
@@ -0,0 +1,37 @@
+// This file was auto-generated by Fern from our API Definition.
+
+import type * as core from "../core/index.js";
+import * as errors from "./index.js";
+
+export function handleNonStatusCodeError(
+ error: core.Fetcher.Error,
+ rawResponse: core.RawResponse,
+ method: string,
+ path: string,
+): never {
+ switch (error.reason) {
+ case "non-json":
+ throw new errors.SeedApiError({
+ statusCode: error.statusCode,
+ body: error.rawBody,
+ rawResponse: rawResponse,
+ });
+ case "body-is-null":
+ throw new errors.SeedApiError({
+ statusCode: error.statusCode,
+ rawResponse: rawResponse,
+ });
+ case "timeout":
+ throw new errors.SeedApiTimeoutError(`Timeout exceeded when calling ${method} ${path}.`);
+ case "unknown":
+ throw new errors.SeedApiError({
+ message: error.errorMessage,
+ rawResponse: rawResponse,
+ });
+ default:
+ throw new errors.SeedApiError({
+ message: "Unknown error",
+ rawResponse: rawResponse,
+ });
+ }
+}
diff --git a/seed/ts-sdk/path-body-property-collision/src/errors/index.ts b/seed/ts-sdk/path-body-property-collision/src/errors/index.ts
new file mode 100644
index 000000000000..09e82b954c26
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/errors/index.ts
@@ -0,0 +1,2 @@
+export { SeedApiError } from "./SeedApiError.js";
+export { SeedApiTimeoutError } from "./SeedApiTimeoutError.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/exports.ts b/seed/ts-sdk/path-body-property-collision/src/exports.ts
new file mode 100644
index 000000000000..7b70ee14fc02
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/exports.ts
@@ -0,0 +1 @@
+export * from "./core/exports.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/index.ts b/seed/ts-sdk/path-body-property-collision/src/index.ts
new file mode 100644
index 000000000000..b5794fd541c9
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/index.ts
@@ -0,0 +1,5 @@
+export * as SeedApi from "./api/index.js";
+export type { BaseClientOptions, BaseRequestOptions } from "./BaseClient.js";
+export { SeedApiClient } from "./Client.js";
+export { SeedApiError, SeedApiTimeoutError } from "./errors/index.js";
+export * from "./exports.js";
diff --git a/seed/ts-sdk/path-body-property-collision/src/version.ts b/seed/ts-sdk/path-body-property-collision/src/version.ts
new file mode 100644
index 000000000000..b643a3e3ea27
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/src/version.ts
@@ -0,0 +1 @@
+export const SDK_VERSION = "0.0.1";
diff --git a/seed/ts-sdk/path-body-property-collision/tests/custom.test.ts b/seed/ts-sdk/path-body-property-collision/tests/custom.test.ts
new file mode 100644
index 000000000000..7f5e031c8396
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/custom.test.ts
@@ -0,0 +1,13 @@
+/**
+ * This is a custom test file, if you wish to add more tests
+ * to your SDK.
+ * Be sure to mark this file in `.fernignore`.
+ *
+ * If you include example requests/responses in your fern definition,
+ * you will have tests automatically generated for you.
+ */
+describe("test", () => {
+ it("default", () => {
+ expect(true).toBe(true);
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/mock-server/MockServer.ts b/seed/ts-sdk/path-body-property-collision/tests/mock-server/MockServer.ts
new file mode 100644
index 000000000000..954872157d52
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/mock-server/MockServer.ts
@@ -0,0 +1,29 @@
+import type { RequestHandlerOptions } from "msw";
+import type { SetupServer } from "msw/node";
+
+import { mockEndpointBuilder } from "./mockEndpointBuilder";
+
+export interface MockServerOptions {
+ baseUrl: string;
+ server: SetupServer;
+}
+
+export class MockServer {
+ private readonly server: SetupServer;
+ public readonly baseUrl: string;
+
+ constructor({ baseUrl, server }: MockServerOptions) {
+ this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
+ this.server = server;
+ }
+
+ public mockEndpoint(options?: RequestHandlerOptions): ReturnType {
+ const builder = mockEndpointBuilder({
+ once: options?.once ?? true,
+ onBuild: (handler) => {
+ this.server.use(handler);
+ },
+ }).baseUrl(this.baseUrl);
+ return builder;
+ }
+}
diff --git a/seed/ts-sdk/path-body-property-collision/tests/mock-server/MockServerPool.ts b/seed/ts-sdk/path-body-property-collision/tests/mock-server/MockServerPool.ts
new file mode 100644
index 000000000000..e1a90f7fb2e3
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/mock-server/MockServerPool.ts
@@ -0,0 +1,106 @@
+import { setupServer } from "msw/node";
+
+import { fromJson, toJson } from "../../src/core/json";
+import { MockServer } from "./MockServer";
+import { randomBaseUrl } from "./randomBaseUrl";
+
+const mswServer = setupServer();
+interface MockServerOptions {
+ baseUrl?: string;
+}
+
+async function formatHttpRequest(request: Request, id?: string): Promise {
+ try {
+ const clone = request.clone();
+ const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n");
+
+ let body = "";
+ try {
+ const contentType = clone.headers.get("content-type");
+ if (contentType?.includes("application/json")) {
+ body = toJson(fromJson(await clone.text()), undefined, 2);
+ } else if (clone.body) {
+ body = await clone.text();
+ }
+ } catch (_e) {
+ body = "(unable to parse body)";
+ }
+
+ const title = id ? `### Request ${id} ###\n` : "";
+ const firstLine = `${title}${request.method} ${request.url.toString()} HTTP/1.1`;
+
+ return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`;
+ } catch (e) {
+ return `Error formatting request: ${e}`;
+ }
+}
+
+async function formatHttpResponse(response: Response, id?: string): Promise {
+ try {
+ const clone = response.clone();
+ const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n");
+
+ let body = "";
+ try {
+ const contentType = clone.headers.get("content-type");
+ if (contentType?.includes("application/json")) {
+ body = toJson(fromJson(await clone.text()), undefined, 2);
+ } else if (clone.body) {
+ body = await clone.text();
+ }
+ } catch (_e) {
+ body = "(unable to parse body)";
+ }
+
+ const title = id ? `### Response for ${id} ###\n` : "";
+ const firstLine = `${title}HTTP/1.1 ${response.status} ${response.statusText}`;
+
+ return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`;
+ } catch (e) {
+ return `Error formatting response: ${e}`;
+ }
+}
+
+class MockServerPool {
+ private servers: MockServer[] = [];
+
+ public createServer(options?: Partial): MockServer {
+ const baseUrl = options?.baseUrl || randomBaseUrl();
+ const server = new MockServer({ baseUrl, server: mswServer });
+ this.servers.push(server);
+ return server;
+ }
+
+ public getServers(): MockServer[] {
+ return [...this.servers];
+ }
+
+ public listen(): void {
+ const onUnhandledRequest = process.env.LOG_LEVEL === "debug" ? "warn" : "bypass";
+ mswServer.listen({ onUnhandledRequest });
+
+ if (process.env.LOG_LEVEL === "debug") {
+ mswServer.events.on("request:start", async ({ request, requestId }) => {
+ const formattedRequest = await formatHttpRequest(request, requestId);
+ console.debug(`request:start\n${formattedRequest}`);
+ });
+
+ mswServer.events.on("request:unhandled", async ({ request, requestId }) => {
+ const formattedRequest = await formatHttpRequest(request, requestId);
+ console.debug(`request:unhandled\n${formattedRequest}`);
+ });
+
+ mswServer.events.on("response:mocked", async ({ request, response, requestId }) => {
+ const formattedResponse = await formatHttpResponse(response, requestId);
+ console.debug(`response:mocked\n${formattedResponse}`);
+ });
+ }
+ }
+
+ public close(): void {
+ this.servers = [];
+ mswServer.close();
+ }
+}
+
+export const mockServerPool = new MockServerPool();
diff --git a/seed/ts-sdk/path-body-property-collision/tests/mock-server/mockEndpointBuilder.ts b/seed/ts-sdk/path-body-property-collision/tests/mock-server/mockEndpointBuilder.ts
new file mode 100644
index 000000000000..1b0e51079e6b
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/mock-server/mockEndpointBuilder.ts
@@ -0,0 +1,227 @@
+import { type DefaultBodyType, type HttpHandler, HttpResponse, type HttpResponseResolver, http } from "msw";
+
+import { url } from "../../src/core";
+import { toJson } from "../../src/core/json";
+import { withFormUrlEncoded } from "./withFormUrlEncoded";
+import { withHeaders } from "./withHeaders";
+import { withJson } from "./withJson";
+
+type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
+
+interface MethodStage {
+ baseUrl(baseUrl: string): MethodStage;
+ all(path: string): RequestHeadersStage;
+ get(path: string): RequestHeadersStage;
+ post(path: string): RequestHeadersStage;
+ put(path: string): RequestHeadersStage;
+ delete(path: string): RequestHeadersStage;
+ patch(path: string): RequestHeadersStage;
+ options(path: string): RequestHeadersStage;
+ head(path: string): RequestHeadersStage;
+}
+
+interface RequestHeadersStage extends RequestBodyStage, ResponseStage {
+ header(name: string, value: string): RequestHeadersStage;
+ headers(headers: Record): RequestBodyStage;
+}
+
+interface RequestBodyStage extends ResponseStage {
+ jsonBody(body: unknown): ResponseStage;
+ formUrlEncodedBody(body: unknown): ResponseStage;
+}
+
+interface ResponseStage {
+ respondWith(): ResponseStatusStage;
+}
+interface ResponseStatusStage {
+ statusCode(statusCode: number): ResponseHeaderStage;
+}
+
+interface ResponseHeaderStage extends ResponseBodyStage, BuildStage {
+ header(name: string, value: string): ResponseHeaderStage;
+ headers(headers: Record): ResponseHeaderStage;
+}
+
+interface ResponseBodyStage {
+ jsonBody(body: unknown): BuildStage;
+}
+
+interface BuildStage {
+ build(): HttpHandler;
+}
+
+export interface HttpHandlerBuilderOptions {
+ onBuild?: (handler: HttpHandler) => void;
+ once?: boolean;
+}
+
+class RequestBuilder implements MethodStage, RequestHeadersStage, RequestBodyStage, ResponseStage {
+ private method: HttpMethod = "get";
+ private _baseUrl: string = "";
+ private path: string = "/";
+ private readonly predicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[] = [];
+ private readonly handlerOptions?: HttpHandlerBuilderOptions;
+
+ constructor(options?: HttpHandlerBuilderOptions) {
+ this.handlerOptions = options;
+ }
+
+ baseUrl(baseUrl: string): MethodStage {
+ this._baseUrl = baseUrl;
+ return this;
+ }
+
+ all(path: string): RequestHeadersStage {
+ this.method = "all";
+ this.path = path;
+ return this;
+ }
+
+ get(path: string): RequestHeadersStage {
+ this.method = "get";
+ this.path = path;
+ return this;
+ }
+
+ post(path: string): RequestHeadersStage {
+ this.method = "post";
+ this.path = path;
+ return this;
+ }
+
+ put(path: string): RequestHeadersStage {
+ this.method = "put";
+ this.path = path;
+ return this;
+ }
+
+ delete(path: string): RequestHeadersStage {
+ this.method = "delete";
+ this.path = path;
+ return this;
+ }
+
+ patch(path: string): RequestHeadersStage {
+ this.method = "patch";
+ this.path = path;
+ return this;
+ }
+
+ options(path: string): RequestHeadersStage {
+ this.method = "options";
+ this.path = path;
+ return this;
+ }
+
+ head(path: string): RequestHeadersStage {
+ this.method = "head";
+ this.path = path;
+ return this;
+ }
+
+ header(name: string, value: string): RequestHeadersStage {
+ this.predicates.push((resolver) => withHeaders({ [name]: value }, resolver));
+ return this;
+ }
+
+ headers(headers: Record): RequestBodyStage {
+ this.predicates.push((resolver) => withHeaders(headers, resolver));
+ return this;
+ }
+
+ jsonBody(body: unknown): ResponseStage {
+ if (body === undefined) {
+ throw new Error("Undefined is not valid JSON. Do not call jsonBody if you want an empty body.");
+ }
+ this.predicates.push((resolver) => withJson(body, resolver));
+ return this;
+ }
+
+ formUrlEncodedBody(body: unknown): ResponseStage {
+ if (body === undefined) {
+ throw new Error(
+ "Undefined is not valid for form-urlencoded. Do not call formUrlEncodedBody if you want an empty body.",
+ );
+ }
+ this.predicates.push((resolver) => withFormUrlEncoded(body, resolver));
+ return this;
+ }
+
+ respondWith(): ResponseStatusStage {
+ return new ResponseBuilder(this.method, this.buildUrl(), this.predicates, this.handlerOptions);
+ }
+
+ private buildUrl(): string {
+ return url.join(this._baseUrl, this.path);
+ }
+}
+
+class ResponseBuilder implements ResponseStatusStage, ResponseHeaderStage, ResponseBodyStage, BuildStage {
+ private readonly method: HttpMethod;
+ private readonly url: string;
+ private readonly requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[];
+ private readonly handlerOptions?: HttpHandlerBuilderOptions;
+
+ private responseStatusCode: number = 200;
+ private responseHeaders: Record = {};
+ private responseBody: DefaultBodyType = undefined;
+
+ constructor(
+ method: HttpMethod,
+ url: string,
+ requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[],
+ options?: HttpHandlerBuilderOptions,
+ ) {
+ this.method = method;
+ this.url = url;
+ this.requestPredicates = requestPredicates;
+ this.handlerOptions = options;
+ }
+
+ public statusCode(code: number): ResponseHeaderStage {
+ this.responseStatusCode = code;
+ return this;
+ }
+
+ public header(name: string, value: string): ResponseHeaderStage {
+ this.responseHeaders[name] = value;
+ return this;
+ }
+
+ public headers(headers: Record): ResponseHeaderStage {
+ this.responseHeaders = { ...this.responseHeaders, ...headers };
+ return this;
+ }
+
+ public jsonBody(body: unknown): BuildStage {
+ if (body === undefined) {
+ throw new Error("Undefined is not valid JSON. Do not call jsonBody if you expect an empty body.");
+ }
+ this.responseBody = toJson(body);
+ return this;
+ }
+
+ public build(): HttpHandler {
+ const responseResolver: HttpResponseResolver = () => {
+ const response = new HttpResponse(this.responseBody, {
+ status: this.responseStatusCode,
+ headers: this.responseHeaders,
+ });
+ // if no Content-Type header is set, delete the default text content type that is set
+ if (Object.keys(this.responseHeaders).some((key) => key.toLowerCase() === "content-type") === false) {
+ response.headers.delete("Content-Type");
+ }
+ return response;
+ };
+
+ const finalResolver = this.requestPredicates.reduceRight((acc, predicate) => predicate(acc), responseResolver);
+
+ const handler = http[this.method](this.url, finalResolver, this.handlerOptions);
+ this.handlerOptions?.onBuild?.(handler);
+ return handler;
+ }
+}
+
+export function mockEndpointBuilder(options?: HttpHandlerBuilderOptions): MethodStage {
+ return new RequestBuilder(options);
+}
diff --git a/seed/ts-sdk/path-body-property-collision/tests/mock-server/randomBaseUrl.ts b/seed/ts-sdk/path-body-property-collision/tests/mock-server/randomBaseUrl.ts
new file mode 100644
index 000000000000..031aa6408aca
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/mock-server/randomBaseUrl.ts
@@ -0,0 +1,4 @@
+export function randomBaseUrl(): string {
+ const randomString = Math.random().toString(36).substring(2, 15);
+ return `http://${randomString}.localhost`;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/tests/mock-server/setup.ts b/seed/ts-sdk/path-body-property-collision/tests/mock-server/setup.ts
new file mode 100644
index 000000000000..aeb3a95af7dc
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/mock-server/setup.ts
@@ -0,0 +1,10 @@
+import { afterAll, beforeAll } from "vitest";
+
+import { mockServerPool } from "./MockServerPool";
+
+beforeAll(() => {
+ mockServerPool.listen();
+});
+afterAll(() => {
+ mockServerPool.close();
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/mock-server/withFormUrlEncoded.ts b/seed/ts-sdk/path-body-property-collision/tests/mock-server/withFormUrlEncoded.ts
new file mode 100644
index 000000000000..e9e6ff2d9cf1
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/mock-server/withFormUrlEncoded.ts
@@ -0,0 +1,80 @@
+import { type HttpResponseResolver, passthrough } from "msw";
+
+import { toJson } from "../../src/core/json";
+
+/**
+ * Creates a request matcher that validates if the request form-urlencoded body exactly matches the expected object
+ * @param expectedBody - The exact body object to match against
+ * @param resolver - Response resolver to execute if body matches
+ */
+export function withFormUrlEncoded(expectedBody: unknown, resolver: HttpResponseResolver): HttpResponseResolver {
+ return async (args) => {
+ const { request } = args;
+
+ let clonedRequest: Request;
+ let bodyText: string | undefined;
+ let actualBody: Record;
+ try {
+ clonedRequest = request.clone();
+ bodyText = await clonedRequest.text();
+ if (bodyText === "") {
+ console.error("Request body is empty, expected a form-urlencoded body.");
+ return passthrough();
+ }
+ const params = new URLSearchParams(bodyText);
+ actualBody = {};
+ for (const [key, value] of params.entries()) {
+ actualBody[key] = value;
+ }
+ } catch (error) {
+ console.error(`Error processing form-urlencoded request body:\n\tError: ${error}\n\tBody: ${bodyText}`);
+ return passthrough();
+ }
+
+ const mismatches = findMismatches(actualBody, expectedBody);
+ if (Object.keys(mismatches).length > 0) {
+ console.error("Form-urlencoded body mismatch:", toJson(mismatches, undefined, 2));
+ return passthrough();
+ }
+
+ return resolver(args);
+ };
+}
+
+function findMismatches(actual: any, expected: any): Record {
+ const mismatches: Record = {};
+
+ if (typeof actual !== typeof expected) {
+ return { value: { actual, expected } };
+ }
+
+ if (typeof actual !== "object" || actual === null || expected === null) {
+ if (actual !== expected) {
+ return { value: { actual, expected } };
+ }
+ return {};
+ }
+
+ const actualKeys = Object.keys(actual);
+ const expectedKeys = Object.keys(expected);
+
+ const allKeys = new Set([...actualKeys, ...expectedKeys]);
+
+ for (const key of allKeys) {
+ if (!expectedKeys.includes(key)) {
+ if (actual[key] === undefined) {
+ continue;
+ }
+ mismatches[key] = { actual: actual[key], expected: undefined };
+ } else if (!actualKeys.includes(key)) {
+ if (expected[key] === undefined) {
+ continue;
+ }
+ mismatches[key] = { actual: undefined, expected: expected[key] };
+ } else if (actual[key] !== expected[key]) {
+ mismatches[key] = { actual: actual[key], expected: expected[key] };
+ }
+ }
+
+ return mismatches;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/tests/mock-server/withHeaders.ts b/seed/ts-sdk/path-body-property-collision/tests/mock-server/withHeaders.ts
new file mode 100644
index 000000000000..6599d2b4a92d
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/mock-server/withHeaders.ts
@@ -0,0 +1,70 @@
+import { type HttpResponseResolver, passthrough } from "msw";
+
+/**
+ * Creates a request matcher that validates if request headers match specified criteria
+ * @param expectedHeaders - Headers to match against
+ * @param resolver - Response resolver to execute if headers match
+ */
+export function withHeaders(
+ expectedHeaders: Record boolean)>,
+ resolver: HttpResponseResolver,
+): HttpResponseResolver {
+ return (args) => {
+ const { request } = args;
+ const { headers } = request;
+
+ const mismatches: Record<
+ string,
+ { actual: string | null; expected: string | RegExp | ((value: string) => boolean) }
+ > = {};
+
+ for (const [key, expectedValue] of Object.entries(expectedHeaders)) {
+ const actualValue = headers.get(key);
+
+ if (actualValue === null) {
+ mismatches[key] = { actual: null, expected: expectedValue };
+ continue;
+ }
+
+ if (typeof expectedValue === "function") {
+ if (!expectedValue(actualValue)) {
+ mismatches[key] = { actual: actualValue, expected: expectedValue };
+ }
+ } else if (expectedValue instanceof RegExp) {
+ if (!expectedValue.test(actualValue)) {
+ mismatches[key] = { actual: actualValue, expected: expectedValue };
+ }
+ } else if (expectedValue !== actualValue) {
+ mismatches[key] = { actual: actualValue, expected: expectedValue };
+ }
+ }
+
+ if (Object.keys(mismatches).length > 0) {
+ const formattedMismatches = formatHeaderMismatches(mismatches);
+ console.error("Header mismatch:", formattedMismatches);
+ return passthrough();
+ }
+
+ return resolver(args);
+ };
+}
+
+function formatHeaderMismatches(
+ mismatches: Record boolean) }>,
+): Record {
+ const formatted: Record = {};
+
+ for (const [key, { actual, expected }] of Object.entries(mismatches)) {
+ formatted[key] = {
+ actual,
+ expected:
+ expected instanceof RegExp
+ ? expected.toString()
+ : typeof expected === "function"
+ ? "[Function]"
+ : expected,
+ };
+ }
+
+ return formatted;
+}
diff --git a/seed/ts-sdk/path-body-property-collision/tests/mock-server/withJson.ts b/seed/ts-sdk/path-body-property-collision/tests/mock-server/withJson.ts
new file mode 100644
index 000000000000..b627638b015f
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/mock-server/withJson.ts
@@ -0,0 +1,158 @@
+import { type HttpResponseResolver, passthrough } from "msw";
+
+import { fromJson, toJson } from "../../src/core/json";
+
+/**
+ * Creates a request matcher that validates if the request JSON body exactly matches the expected object
+ * @param expectedBody - The exact body object to match against
+ * @param resolver - Response resolver to execute if body matches
+ */
+export function withJson(expectedBody: unknown, resolver: HttpResponseResolver): HttpResponseResolver {
+ return async (args) => {
+ const { request } = args;
+
+ let clonedRequest: Request;
+ let bodyText: string | undefined;
+ let actualBody: unknown;
+ try {
+ clonedRequest = request.clone();
+ bodyText = await clonedRequest.text();
+ if (bodyText === "") {
+ console.error("Request body is empty, expected a JSON object.");
+ return passthrough();
+ }
+ actualBody = fromJson(bodyText);
+ } catch (error) {
+ console.error(`Error processing request body:\n\tError: ${error}\n\tBody: ${bodyText}`);
+ return passthrough();
+ }
+
+ const mismatches = findMismatches(actualBody, expectedBody);
+ if (Object.keys(mismatches).filter((key) => !key.startsWith("pagination.")).length > 0) {
+ console.error("JSON body mismatch:", toJson(mismatches, undefined, 2));
+ return passthrough();
+ }
+
+ return resolver(args);
+ };
+}
+
+function findMismatches(actual: any, expected: any): Record {
+ const mismatches: Record = {};
+
+ if (typeof actual !== typeof expected) {
+ if (areEquivalent(actual, expected)) {
+ return {};
+ }
+ return { value: { actual, expected } };
+ }
+
+ if (typeof actual !== "object" || actual === null || expected === null) {
+ if (actual !== expected) {
+ if (areEquivalent(actual, expected)) {
+ return {};
+ }
+ return { value: { actual, expected } };
+ }
+ return {};
+ }
+
+ if (Array.isArray(actual) && Array.isArray(expected)) {
+ if (actual.length !== expected.length) {
+ return { length: { actual: actual.length, expected: expected.length } };
+ }
+
+ const arrayMismatches: Record = {};
+ for (let i = 0; i < actual.length; i++) {
+ const itemMismatches = findMismatches(actual[i], expected[i]);
+ if (Object.keys(itemMismatches).length > 0) {
+ for (const [mismatchKey, mismatchValue] of Object.entries(itemMismatches)) {
+ arrayMismatches[`[${i}]${mismatchKey === "value" ? "" : `.${mismatchKey}`}`] = mismatchValue;
+ }
+ }
+ }
+ return arrayMismatches;
+ }
+
+ const actualKeys = Object.keys(actual);
+ const expectedKeys = Object.keys(expected);
+
+ const allKeys = new Set([...actualKeys, ...expectedKeys]);
+
+ for (const key of allKeys) {
+ if (!expectedKeys.includes(key)) {
+ if (actual[key] === undefined) {
+ continue; // Skip undefined values in actual
+ }
+ mismatches[key] = { actual: actual[key], expected: undefined };
+ } else if (!actualKeys.includes(key)) {
+ if (expected[key] === undefined) {
+ continue; // Skip undefined values in expected
+ }
+ mismatches[key] = { actual: undefined, expected: expected[key] };
+ } else if (
+ typeof actual[key] === "object" &&
+ actual[key] !== null &&
+ typeof expected[key] === "object" &&
+ expected[key] !== null
+ ) {
+ const nestedMismatches = findMismatches(actual[key], expected[key]);
+ if (Object.keys(nestedMismatches).length > 0) {
+ for (const [nestedKey, nestedValue] of Object.entries(nestedMismatches)) {
+ mismatches[`${key}${nestedKey === "value" ? "" : `.${nestedKey}`}`] = nestedValue;
+ }
+ }
+ } else if (actual[key] !== expected[key]) {
+ if (areEquivalent(actual[key], expected[key])) {
+ continue;
+ }
+ mismatches[key] = { actual: actual[key], expected: expected[key] };
+ }
+ }
+
+ return mismatches;
+}
+
+function areEquivalent(actual: unknown, expected: unknown): boolean {
+ if (actual === expected) {
+ return true;
+ }
+ if (isEquivalentBigInt(actual, expected)) {
+ return true;
+ }
+ if (isEquivalentDatetime(actual, expected)) {
+ return true;
+ }
+ return false;
+}
+
+function isEquivalentBigInt(actual: unknown, expected: unknown) {
+ if (typeof actual === "number") {
+ actual = BigInt(actual);
+ }
+ if (typeof expected === "number") {
+ expected = BigInt(expected);
+ }
+ if (typeof actual === "bigint" && typeof expected === "bigint") {
+ return actual === expected;
+ }
+ return false;
+}
+
+function isEquivalentDatetime(str1: unknown, str2: unknown): boolean {
+ if (typeof str1 !== "string" || typeof str2 !== "string") {
+ return false;
+ }
+ const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
+ if (!isoDatePattern.test(str1) || !isoDatePattern.test(str2)) {
+ return false;
+ }
+
+ try {
+ const date1 = new Date(str1).getTime();
+ const date2 = new Date(str2).getTime();
+ return date1 === date2;
+ } catch {
+ return false;
+ }
+}
diff --git a/seed/ts-sdk/path-body-property-collision/tests/setup.ts b/seed/ts-sdk/path-body-property-collision/tests/setup.ts
new file mode 100644
index 000000000000..a5651f81ba10
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/setup.ts
@@ -0,0 +1,80 @@
+import { expect } from "vitest";
+
+interface CustomMatchers {
+ toContainHeaders(expectedHeaders: Record): R;
+}
+
+declare module "vitest" {
+ interface Assertion extends CustomMatchers {}
+ interface AsymmetricMatchersContaining extends CustomMatchers {}
+}
+
+expect.extend({
+ toContainHeaders(actual: unknown, expectedHeaders: Record) {
+ const isHeaders = actual instanceof Headers;
+ const isPlainObject = typeof actual === "object" && actual !== null && !Array.isArray(actual);
+
+ if (!isHeaders && !isPlainObject) {
+ throw new TypeError("Received value must be an instance of Headers or a plain object!");
+ }
+
+ if (typeof expectedHeaders !== "object" || expectedHeaders === null || Array.isArray(expectedHeaders)) {
+ throw new TypeError("Expected headers must be a plain object!");
+ }
+
+ const missingHeaders: string[] = [];
+ const mismatchedHeaders: Array<{ key: string; expected: string; actual: string | null }> = [];
+
+ for (const [key, value] of Object.entries(expectedHeaders)) {
+ let actualValue: string | null = null;
+
+ if (isHeaders) {
+ // Headers.get() is already case-insensitive
+ actualValue = (actual as Headers).get(key);
+ } else {
+ // For plain objects, do case-insensitive lookup
+ const actualObj = actual as Record;
+ const lowerKey = key.toLowerCase();
+ const foundKey = Object.keys(actualObj).find((k) => k.toLowerCase() === lowerKey);
+ actualValue = foundKey ? actualObj[foundKey] : null;
+ }
+
+ if (actualValue === null || actualValue === undefined) {
+ missingHeaders.push(key);
+ } else if (actualValue !== value) {
+ mismatchedHeaders.push({ key, expected: value, actual: actualValue });
+ }
+ }
+
+ const pass = missingHeaders.length === 0 && mismatchedHeaders.length === 0;
+
+ const actualType = isHeaders ? "Headers" : "object";
+
+ if (pass) {
+ return {
+ message: () => `expected ${actualType} not to contain ${this.utils.printExpected(expectedHeaders)}`,
+ pass: true,
+ };
+ } else {
+ const messages: string[] = [];
+
+ if (missingHeaders.length > 0) {
+ messages.push(`Missing headers: ${this.utils.printExpected(missingHeaders.join(", "))}`);
+ }
+
+ if (mismatchedHeaders.length > 0) {
+ const mismatches = mismatchedHeaders.map(
+ ({ key, expected, actual }) =>
+ `${key}: expected ${this.utils.printExpected(expected)} but got ${this.utils.printReceived(actual)}`,
+ );
+ messages.push(mismatches.join("\n"));
+ }
+
+ return {
+ message: () =>
+ `expected ${actualType} to contain ${this.utils.printExpected(expectedHeaders)}\n\n${messages.join("\n")}`,
+ pass: false,
+ };
+ }
+ },
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/tsconfig.json b/seed/ts-sdk/path-body-property-collision/tests/tsconfig.json
new file mode 100644
index 000000000000..a477df47920c
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "outDir": null,
+ "rootDir": "..",
+ "baseUrl": "..",
+ "types": ["vitest/globals"]
+ },
+ "include": ["../src", "../tests"],
+ "exclude": []
+}
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/Fetcher.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/Fetcher.test.ts
new file mode 100644
index 000000000000..60df2b5e4824
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/Fetcher.test.ts
@@ -0,0 +1,261 @@
+import fs from "fs";
+import { join } from "path";
+import stream from "stream";
+import type { BinaryResponse } from "../../../src/core";
+import { type Fetcher, fetcherImpl } from "../../../src/core/fetcher/Fetcher";
+
+describe("Test fetcherImpl", () => {
+ it("should handle successful request", async () => {
+ const mockArgs: Fetcher.Args = {
+ url: "https://httpbin.org/post",
+ method: "POST",
+ headers: { "X-Test": "x-test-header" },
+ body: { data: "test" },
+ contentType: "application/json",
+ requestType: "json",
+ maxRetries: 0,
+ responseType: "json",
+ };
+
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ data: "test" }), {
+ status: 200,
+ statusText: "OK",
+ }),
+ );
+
+ const result = await fetcherImpl(mockArgs);
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.body).toEqual({ data: "test" });
+ }
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ "https://httpbin.org/post",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.toContainHeaders({ "X-Test": "x-test-header" }),
+ body: JSON.stringify({ data: "test" }),
+ }),
+ );
+ });
+
+ it("should send octet stream", async () => {
+ const url = "https://httpbin.org/post/file";
+ const mockArgs: Fetcher.Args = {
+ url,
+ method: "POST",
+ headers: { "X-Test": "x-test-header" },
+ contentType: "application/octet-stream",
+ requestType: "bytes",
+ maxRetries: 0,
+ responseType: "json",
+ body: fs.createReadStream(join(__dirname, "test-file.txt")),
+ };
+
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ data: "test" }), {
+ status: 200,
+ statusText: "OK",
+ }),
+ );
+
+ const result = await fetcherImpl(mockArgs);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ url,
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.toContainHeaders({ "X-Test": "x-test-header" }),
+ body: expect.any(fs.ReadStream),
+ }),
+ );
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.body).toEqual({ data: "test" });
+ }
+ });
+
+ it("should receive file as stream", async () => {
+ const url = "https://httpbin.org/post/file";
+ const mockArgs: Fetcher.Args = {
+ url,
+ method: "GET",
+ headers: { "X-Test": "x-test-header" },
+ maxRetries: 0,
+ responseType: "binary-response",
+ };
+
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(
+ stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
+ {
+ status: 200,
+ statusText: "OK",
+ },
+ ),
+ );
+
+ const result = await fetcherImpl(mockArgs);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ url,
+ expect.objectContaining({
+ method: "GET",
+ headers: expect.toContainHeaders({ "X-Test": "x-test-header" }),
+ }),
+ );
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ const body = result.body as BinaryResponse;
+ expect(body).toBeDefined();
+ expect(body.bodyUsed).toBe(false);
+ expect(typeof body.stream).toBe("function");
+ const stream = body.stream();
+ expect(stream).toBeInstanceOf(ReadableStream);
+ const reader = stream.getReader();
+ const { value } = await reader.read();
+ const decoder = new TextDecoder();
+ const streamContent = decoder.decode(value);
+ expect(streamContent).toBe("This is a test file!\n");
+ expect(body.bodyUsed).toBe(true);
+ }
+ });
+
+ it("should receive file as blob", async () => {
+ const url = "https://httpbin.org/post/file";
+ const mockArgs: Fetcher.Args = {
+ url,
+ method: "GET",
+ headers: { "X-Test": "x-test-header" },
+ maxRetries: 0,
+ responseType: "binary-response",
+ };
+
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(
+ stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
+ {
+ status: 200,
+ statusText: "OK",
+ },
+ ),
+ );
+
+ const result = await fetcherImpl(mockArgs);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ url,
+ expect.objectContaining({
+ method: "GET",
+ headers: expect.toContainHeaders({ "X-Test": "x-test-header" }),
+ }),
+ );
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ const body = result.body as BinaryResponse;
+ expect(body).toBeDefined();
+ expect(body.bodyUsed).toBe(false);
+ expect(typeof body.blob).toBe("function");
+ const blob = await body.blob();
+ expect(blob).toBeInstanceOf(Blob);
+ const reader = blob.stream().getReader();
+ const { value } = await reader.read();
+ const decoder = new TextDecoder();
+ const streamContent = decoder.decode(value);
+ expect(streamContent).toBe("This is a test file!\n");
+ expect(body.bodyUsed).toBe(true);
+ }
+ });
+
+ it("should receive file as arraybuffer", async () => {
+ const url = "https://httpbin.org/post/file";
+ const mockArgs: Fetcher.Args = {
+ url,
+ method: "GET",
+ headers: { "X-Test": "x-test-header" },
+ maxRetries: 0,
+ responseType: "binary-response",
+ };
+
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(
+ stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
+ {
+ status: 200,
+ statusText: "OK",
+ },
+ ),
+ );
+
+ const result = await fetcherImpl(mockArgs);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ url,
+ expect.objectContaining({
+ method: "GET",
+ headers: expect.toContainHeaders({ "X-Test": "x-test-header" }),
+ }),
+ );
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ const body = result.body as BinaryResponse;
+ expect(body).toBeDefined();
+ expect(body.bodyUsed).toBe(false);
+ expect(typeof body.arrayBuffer).toBe("function");
+ const arrayBuffer = await body.arrayBuffer();
+ expect(arrayBuffer).toBeInstanceOf(ArrayBuffer);
+ const decoder = new TextDecoder();
+ const streamContent = decoder.decode(new Uint8Array(arrayBuffer));
+ expect(streamContent).toBe("This is a test file!\n");
+ expect(body.bodyUsed).toBe(true);
+ }
+ });
+
+ it("should receive file as bytes", async () => {
+ const url = "https://httpbin.org/post/file";
+ const mockArgs: Fetcher.Args = {
+ url,
+ method: "GET",
+ headers: { "X-Test": "x-test-header" },
+ maxRetries: 0,
+ responseType: "binary-response",
+ };
+
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(
+ stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
+ {
+ status: 200,
+ statusText: "OK",
+ },
+ ),
+ );
+
+ const result = await fetcherImpl(mockArgs);
+
+ expect(global.fetch).toHaveBeenCalledWith(
+ url,
+ expect.objectContaining({
+ method: "GET",
+ headers: expect.toContainHeaders({ "X-Test": "x-test-header" }),
+ }),
+ );
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ const body = result.body as BinaryResponse;
+ expect(body).toBeDefined();
+ expect(body.bodyUsed).toBe(false);
+ expect(typeof body.bytes).toBe("function");
+ if (!body.bytes) {
+ return;
+ }
+ const bytes = await body.bytes();
+ expect(bytes).toBeInstanceOf(Uint8Array);
+ const decoder = new TextDecoder();
+ const streamContent = decoder.decode(bytes);
+ expect(streamContent).toBe("This is a test file!\n");
+ expect(body.bodyUsed).toBe(true);
+ }
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/HttpResponsePromise.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/HttpResponsePromise.test.ts
new file mode 100644
index 000000000000..2ec008e581d8
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/HttpResponsePromise.test.ts
@@ -0,0 +1,143 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { HttpResponsePromise } from "../../../src/core/fetcher/HttpResponsePromise";
+import type { RawResponse, WithRawResponse } from "../../../src/core/fetcher/RawResponse";
+
+describe("HttpResponsePromise", () => {
+ const mockRawResponse: RawResponse = {
+ headers: new Headers(),
+ redirected: false,
+ status: 200,
+ statusText: "OK",
+ type: "basic" as ResponseType,
+ url: "https://example.com",
+ };
+ const mockData = { id: "123", name: "test" };
+ const mockWithRawResponse: WithRawResponse = {
+ data: mockData,
+ rawResponse: mockRawResponse,
+ };
+
+ describe("fromFunction", () => {
+ it("should create an HttpResponsePromise from a function", async () => {
+ const mockFn = vi
+ .fn<(arg1: string, arg2: string) => Promise>>()
+ .mockResolvedValue(mockWithRawResponse);
+
+ const responsePromise = HttpResponsePromise.fromFunction(mockFn, "arg1", "arg2");
+
+ const result = await responsePromise;
+ expect(result).toEqual(mockData);
+ expect(mockFn).toHaveBeenCalledWith("arg1", "arg2");
+
+ const resultWithRawResponse = await responsePromise.withRawResponse();
+ expect(resultWithRawResponse).toEqual({
+ data: mockData,
+ rawResponse: mockRawResponse,
+ });
+ });
+ });
+
+ describe("fromPromise", () => {
+ it("should create an HttpResponsePromise from a promise", async () => {
+ const promise = Promise.resolve(mockWithRawResponse);
+
+ const responsePromise = HttpResponsePromise.fromPromise(promise);
+
+ const result = await responsePromise;
+ expect(result).toEqual(mockData);
+
+ const resultWithRawResponse = await responsePromise.withRawResponse();
+ expect(resultWithRawResponse).toEqual({
+ data: mockData,
+ rawResponse: mockRawResponse,
+ });
+ });
+ });
+
+ describe("fromExecutor", () => {
+ it("should create an HttpResponsePromise from an executor function", async () => {
+ const responsePromise = HttpResponsePromise.fromExecutor((resolve) => {
+ resolve(mockWithRawResponse);
+ });
+
+ const result = await responsePromise;
+ expect(result).toEqual(mockData);
+
+ const resultWithRawResponse = await responsePromise.withRawResponse();
+ expect(resultWithRawResponse).toEqual({
+ data: mockData,
+ rawResponse: mockRawResponse,
+ });
+ });
+ });
+
+ describe("fromResult", () => {
+ it("should create an HttpResponsePromise from a result", async () => {
+ const responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse);
+
+ const result = await responsePromise;
+ expect(result).toEqual(mockData);
+
+ const resultWithRawResponse = await responsePromise.withRawResponse();
+ expect(resultWithRawResponse).toEqual({
+ data: mockData,
+ rawResponse: mockRawResponse,
+ });
+ });
+ });
+
+ describe("Promise methods", () => {
+ let responsePromise: HttpResponsePromise;
+
+ beforeEach(() => {
+ responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse);
+ });
+
+ it("should support then() method", async () => {
+ const result = await responsePromise.then((data) => ({
+ ...data,
+ modified: true,
+ }));
+
+ expect(result).toEqual({
+ ...mockData,
+ modified: true,
+ });
+ });
+
+ it("should support catch() method", async () => {
+ const errorResponsePromise = HttpResponsePromise.fromExecutor((_, reject) => {
+ reject(new Error("Test error"));
+ });
+
+ const catchSpy = vi.fn();
+ await errorResponsePromise.catch(catchSpy);
+
+ expect(catchSpy).toHaveBeenCalled();
+ const error = catchSpy.mock.calls[0]?.[0];
+ expect(error).toBeInstanceOf(Error);
+ expect((error as Error).message).toBe("Test error");
+ });
+
+ it("should support finally() method", async () => {
+ const finallySpy = vi.fn();
+ await responsePromise.finally(finallySpy);
+
+ expect(finallySpy).toHaveBeenCalled();
+ });
+ });
+
+ describe("withRawResponse", () => {
+ it("should return both data and raw response", async () => {
+ const responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse);
+
+ const result = await responsePromise.withRawResponse();
+
+ expect(result).toEqual({
+ data: mockData,
+ rawResponse: mockRawResponse,
+ });
+ });
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/RawResponse.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/RawResponse.test.ts
new file mode 100644
index 000000000000..375ee3f38064
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/RawResponse.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+
+import { toRawResponse } from "../../../src/core/fetcher/RawResponse";
+
+describe("RawResponse", () => {
+ describe("toRawResponse", () => {
+ it("should convert Response to RawResponse by removing body, bodyUsed, and ok properties", () => {
+ const mockHeaders = new Headers({ "content-type": "application/json" });
+ const mockResponse = {
+ body: "test body",
+ bodyUsed: false,
+ ok: true,
+ headers: mockHeaders,
+ redirected: false,
+ status: 200,
+ statusText: "OK",
+ type: "basic" as ResponseType,
+ url: "https://example.com",
+ };
+
+ const result = toRawResponse(mockResponse as unknown as Response);
+
+ expect("body" in result).toBe(false);
+ expect("bodyUsed" in result).toBe(false);
+ expect("ok" in result).toBe(false);
+ expect(result.headers).toBe(mockHeaders);
+ expect(result.redirected).toBe(false);
+ expect(result.status).toBe(200);
+ expect(result.statusText).toBe("OK");
+ expect(result.type).toBe("basic");
+ expect(result.url).toBe("https://example.com");
+ });
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/createRequestUrl.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/createRequestUrl.test.ts
new file mode 100644
index 000000000000..a92f1b5e81d1
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/createRequestUrl.test.ts
@@ -0,0 +1,163 @@
+import { createRequestUrl } from "../../../src/core/fetcher/createRequestUrl";
+
+describe("Test createRequestUrl", () => {
+ const BASE_URL = "https://api.example.com";
+
+ interface TestCase {
+ description: string;
+ baseUrl: string;
+ queryParams?: Record;
+ expected: string;
+ }
+
+ const testCases: TestCase[] = [
+ {
+ description: "should return the base URL when no query parameters are provided",
+ baseUrl: BASE_URL,
+ expected: BASE_URL,
+ },
+ {
+ description: "should append simple query parameters",
+ baseUrl: BASE_URL,
+ queryParams: { key: "value", another: "param" },
+ expected: "https://api.example.com?key=value&another=param",
+ },
+ {
+ description: "should handle array query parameters",
+ baseUrl: BASE_URL,
+ queryParams: { items: ["a", "b", "c"] },
+ expected: "https://api.example.com?items=a&items=b&items=c",
+ },
+ {
+ description: "should handle object query parameters",
+ baseUrl: BASE_URL,
+ queryParams: { filter: { name: "John", age: 30 } },
+ expected: "https://api.example.com?filter%5Bname%5D=John&filter%5Bage%5D=30",
+ },
+ {
+ description: "should handle mixed types of query parameters",
+ baseUrl: BASE_URL,
+ queryParams: {
+ simple: "value",
+ array: ["x", "y"],
+ object: { key: "value" },
+ },
+ expected: "https://api.example.com?simple=value&array=x&array=y&object%5Bkey%5D=value",
+ },
+ {
+ description: "should handle empty query parameters object",
+ baseUrl: BASE_URL,
+ queryParams: {},
+ expected: BASE_URL,
+ },
+ {
+ description: "should encode special characters in query parameters",
+ baseUrl: BASE_URL,
+ queryParams: { special: "a&b=c d" },
+ expected: "https://api.example.com?special=a%26b%3Dc%20d",
+ },
+ {
+ description: "should handle numeric values",
+ baseUrl: BASE_URL,
+ queryParams: { count: 42, price: 19.99, active: 1, inactive: 0 },
+ expected: "https://api.example.com?count=42&price=19.99&active=1&inactive=0",
+ },
+ {
+ description: "should handle boolean values",
+ baseUrl: BASE_URL,
+ queryParams: { enabled: true, disabled: false },
+ expected: "https://api.example.com?enabled=true&disabled=false",
+ },
+ {
+ description: "should handle null and undefined values",
+ baseUrl: BASE_URL,
+ queryParams: {
+ valid: "value",
+ nullValue: null,
+ undefinedValue: undefined,
+ emptyString: "",
+ },
+ expected: "https://api.example.com?valid=value&nullValue=&emptyString=",
+ },
+ {
+ description: "should handle deeply nested objects",
+ baseUrl: BASE_URL,
+ queryParams: {
+ user: {
+ profile: {
+ name: "John",
+ settings: { theme: "dark" },
+ },
+ },
+ },
+ expected:
+ "https://api.example.com?user%5Bprofile%5D%5Bname%5D=John&user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark",
+ },
+ {
+ description: "should handle arrays of objects",
+ baseUrl: BASE_URL,
+ queryParams: {
+ users: [
+ { name: "John", age: 30 },
+ { name: "Jane", age: 25 },
+ ],
+ },
+ expected:
+ "https://api.example.com?users%5Bname%5D=John&users%5Bage%5D=30&users%5Bname%5D=Jane&users%5Bage%5D=25",
+ },
+ {
+ description: "should handle mixed arrays",
+ baseUrl: BASE_URL,
+ queryParams: {
+ mixed: ["string", 42, true, { key: "value" }],
+ },
+ expected: "https://api.example.com?mixed=string&mixed=42&mixed=true&mixed%5Bkey%5D=value",
+ },
+ {
+ description: "should handle empty arrays",
+ baseUrl: BASE_URL,
+ queryParams: { emptyArray: [] },
+ expected: BASE_URL,
+ },
+ {
+ description: "should handle empty objects",
+ baseUrl: BASE_URL,
+ queryParams: { emptyObject: {} },
+ expected: BASE_URL,
+ },
+ {
+ description: "should handle special characters in keys",
+ baseUrl: BASE_URL,
+ queryParams: { "key with spaces": "value", "key[with]brackets": "value" },
+ expected: "https://api.example.com?key%20with%20spaces=value&key%5Bwith%5Dbrackets=value",
+ },
+ {
+ description: "should handle URL with existing query parameters",
+ baseUrl: "https://api.example.com?existing=param",
+ queryParams: { new: "value" },
+ expected: "https://api.example.com?existing=param?new=value",
+ },
+ {
+ description: "should handle complex nested structures",
+ baseUrl: BASE_URL,
+ queryParams: {
+ filters: {
+ status: ["active", "pending"],
+ category: {
+ type: "electronics",
+ subcategories: ["phones", "laptops"],
+ },
+ },
+ sort: { field: "name", direction: "asc" },
+ },
+ expected:
+ "https://api.example.com?filters%5Bstatus%5D=active&filters%5Bstatus%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc",
+ },
+ ];
+
+ testCases.forEach(({ description, baseUrl, queryParams, expected }) => {
+ it(description, () => {
+ expect(createRequestUrl(baseUrl, queryParams)).toBe(expected);
+ });
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/getRequestBody.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/getRequestBody.test.ts
new file mode 100644
index 000000000000..8a6c3a57e211
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/getRequestBody.test.ts
@@ -0,0 +1,129 @@
+import { getRequestBody } from "../../../src/core/fetcher/getRequestBody";
+import { RUNTIME } from "../../../src/core/runtime";
+
+describe("Test getRequestBody", () => {
+ interface TestCase {
+ description: string;
+ input: any;
+ type: "json" | "form" | "file" | "bytes" | "other";
+ expected: any;
+ skipCondition?: () => boolean;
+ }
+
+ const testCases: TestCase[] = [
+ {
+ description: "should stringify body if not FormData in Node environment",
+ input: { key: "value" },
+ type: "json",
+ expected: '{"key":"value"}',
+ skipCondition: () => RUNTIME.type !== "node",
+ },
+ {
+ description: "should stringify body if not FormData in browser environment",
+ input: { key: "value" },
+ type: "json",
+ expected: '{"key":"value"}',
+ skipCondition: () => RUNTIME.type !== "browser",
+ },
+ {
+ description: "should return the Uint8Array",
+ input: new Uint8Array([1, 2, 3]),
+ type: "bytes",
+ expected: new Uint8Array([1, 2, 3]),
+ },
+ {
+ description: "should serialize objects for form-urlencoded content type",
+ input: { username: "johndoe", email: "john@example.com" },
+ type: "form",
+ expected: "username=johndoe&email=john%40example.com",
+ },
+ {
+ description: "should serialize complex nested objects and arrays for form-urlencoded content type",
+ input: {
+ user: {
+ profile: {
+ name: "John Doe",
+ settings: {
+ theme: "dark",
+ notifications: true,
+ },
+ },
+ tags: ["admin", "user"],
+ contacts: [
+ { type: "email", value: "john@example.com" },
+ { type: "phone", value: "+1234567890" },
+ ],
+ },
+ filters: {
+ status: ["active", "pending"],
+ metadata: {
+ created: "2024-01-01",
+ categories: ["electronics", "books"],
+ },
+ },
+ preferences: ["notifications", "updates"],
+ },
+ type: "form",
+ expected:
+ "user%5Bprofile%5D%5Bname%5D=John%20Doe&" +
+ "user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark&" +
+ "user%5Bprofile%5D%5Bsettings%5D%5Bnotifications%5D=true&" +
+ "user%5Btags%5D=admin&" +
+ "user%5Btags%5D=user&" +
+ "user%5Bcontacts%5D%5Btype%5D=email&" +
+ "user%5Bcontacts%5D%5Bvalue%5D=john%40example.com&" +
+ "user%5Bcontacts%5D%5Btype%5D=phone&" +
+ "user%5Bcontacts%5D%5Bvalue%5D=%2B1234567890&" +
+ "filters%5Bstatus%5D=active&" +
+ "filters%5Bstatus%5D=pending&" +
+ "filters%5Bmetadata%5D%5Bcreated%5D=2024-01-01&" +
+ "filters%5Bmetadata%5D%5Bcategories%5D=electronics&" +
+ "filters%5Bmetadata%5D%5Bcategories%5D=books&" +
+ "preferences=notifications&" +
+ "preferences=updates",
+ },
+ {
+ description: "should return the input for pre-serialized form-urlencoded strings",
+ input: "key=value&another=param",
+ type: "other",
+ expected: "key=value&another=param",
+ },
+ {
+ description: "should JSON stringify objects",
+ input: { key: "value" },
+ type: "json",
+ expected: '{"key":"value"}',
+ },
+ ];
+
+ testCases.forEach(({ description, input, type, expected, skipCondition }) => {
+ it(description, async () => {
+ if (skipCondition?.()) {
+ return;
+ }
+
+ const result = await getRequestBody({
+ body: input,
+ type,
+ });
+
+ if (input instanceof Uint8Array) {
+ expect(result).toBe(input);
+ } else {
+ expect(result).toBe(expected);
+ }
+ });
+ });
+
+ it("should return FormData in browser environment", async () => {
+ if (RUNTIME.type === "browser") {
+ const formData = new FormData();
+ formData.append("key", "value");
+ const result = await getRequestBody({
+ body: formData,
+ type: "file",
+ });
+ expect(result).toBe(formData);
+ }
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/getResponseBody.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/getResponseBody.test.ts
new file mode 100644
index 000000000000..ad6be7fc2c9b
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/getResponseBody.test.ts
@@ -0,0 +1,97 @@
+import { getResponseBody } from "../../../src/core/fetcher/getResponseBody";
+
+import { RUNTIME } from "../../../src/core/runtime";
+
+describe("Test getResponseBody", () => {
+ interface SimpleTestCase {
+ description: string;
+ responseData: string | Record;
+ responseType?: "blob" | "sse" | "streaming" | "text";
+ expected: any;
+ skipCondition?: () => boolean;
+ }
+
+ const simpleTestCases: SimpleTestCase[] = [
+ {
+ description: "should handle text response type",
+ responseData: "test text",
+ responseType: "text",
+ expected: "test text",
+ },
+ {
+ description: "should handle JSON response",
+ responseData: { key: "value" },
+ expected: { key: "value" },
+ },
+ {
+ description: "should handle empty response",
+ responseData: "",
+ expected: undefined,
+ },
+ {
+ description: "should handle non-JSON response",
+ responseData: "invalid json",
+ expected: {
+ ok: false,
+ error: {
+ reason: "non-json",
+ statusCode: 200,
+ rawBody: "invalid json",
+ },
+ },
+ },
+ ];
+
+ simpleTestCases.forEach(({ description, responseData, responseType, expected, skipCondition }) => {
+ it(description, async () => {
+ if (skipCondition?.()) {
+ return;
+ }
+
+ const mockResponse = new Response(
+ typeof responseData === "string" ? responseData : JSON.stringify(responseData),
+ );
+ const result = await getResponseBody(mockResponse, responseType);
+ expect(result).toEqual(expected);
+ });
+ });
+
+ it("should handle blob response type", async () => {
+ const mockBlob = new Blob(["test"], { type: "text/plain" });
+ const mockResponse = new Response(mockBlob);
+ const result = await getResponseBody(mockResponse, "blob");
+ // @ts-expect-error
+ expect(result.constructor.name).toBe("Blob");
+ });
+
+ it("should handle sse response type", async () => {
+ if (RUNTIME.type === "node") {
+ const mockStream = new ReadableStream();
+ const mockResponse = new Response(mockStream);
+ const result = await getResponseBody(mockResponse, "sse");
+ expect(result).toBe(mockStream);
+ }
+ });
+
+ it("should handle streaming response type", async () => {
+ const encoder = new TextEncoder();
+ const testData = "test stream data";
+ const mockStream = new ReadableStream({
+ start(controller) {
+ controller.enqueue(encoder.encode(testData));
+ controller.close();
+ },
+ });
+
+ const mockResponse = new Response(mockStream);
+ const result = (await getResponseBody(mockResponse, "streaming")) as ReadableStream;
+
+ expect(result).toBeInstanceOf(ReadableStream);
+
+ const reader = result.getReader();
+ const decoder = new TextDecoder();
+ const { value } = await reader.read();
+ const streamContent = decoder.decode(value);
+ expect(streamContent).toBe(testData);
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/logging.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/logging.test.ts
new file mode 100644
index 000000000000..366c9b6ced61
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/logging.test.ts
@@ -0,0 +1,517 @@
+import { fetcherImpl } from "../../../src/core/fetcher/Fetcher";
+
+function createMockLogger() {
+ return {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ };
+}
+
+function mockSuccessResponse(data: unknown = { data: "test" }, status = 200, statusText = "OK") {
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(data), {
+ status,
+ statusText,
+ }),
+ );
+}
+
+function mockErrorResponse(data: unknown = { error: "Error" }, status = 404, statusText = "Not Found") {
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(data), {
+ status,
+ statusText,
+ }),
+ );
+}
+
+describe("Fetcher Logging Integration", () => {
+ describe("Request Logging", () => {
+ it("should log successful request at debug level", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: { test: "data" },
+ contentType: "application/json",
+ requestType: "json",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ method: "POST",
+ url: "https://example.com/api",
+ headers: expect.toContainHeaders({
+ "Content-Type": "application/json",
+ }),
+ hasBody: true,
+ }),
+ );
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "HTTP request succeeded",
+ expect.objectContaining({
+ method: "POST",
+ url: "https://example.com/api",
+ statusCode: 200,
+ }),
+ );
+ });
+
+ it("should not log debug messages at info level for successful requests", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "info",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).not.toHaveBeenCalled();
+ expect(mockLogger.info).not.toHaveBeenCalled();
+ });
+
+ it("should log request with body flag", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "POST",
+ body: { data: "test" },
+ contentType: "application/json",
+ requestType: "json",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ hasBody: true,
+ }),
+ );
+ });
+
+ it("should log request without body flag", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ hasBody: false,
+ }),
+ );
+ });
+
+ it("should not log when silent mode is enabled", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: true,
+ },
+ });
+
+ expect(mockLogger.debug).not.toHaveBeenCalled();
+ expect(mockLogger.info).not.toHaveBeenCalled();
+ expect(mockLogger.warn).not.toHaveBeenCalled();
+ expect(mockLogger.error).not.toHaveBeenCalled();
+ });
+
+ it("should not log when no logging config is provided", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ });
+
+ expect(mockLogger.debug).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Error Logging", () => {
+ it("should log 4xx errors at error level", async () => {
+ const mockLogger = createMockLogger();
+ mockErrorResponse({ error: "Not found" }, 404, "Not Found");
+
+ const result = await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "error",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(result.ok).toBe(false);
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ "HTTP request failed with error status",
+ expect.objectContaining({
+ method: "GET",
+ url: "https://example.com/api",
+ statusCode: 404,
+ }),
+ );
+ });
+
+ it("should log 5xx errors at error level", async () => {
+ const mockLogger = createMockLogger();
+ mockErrorResponse({ error: "Internal error" }, 500, "Internal Server Error");
+
+ const result = await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "error",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(result.ok).toBe(false);
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ "HTTP request failed with error status",
+ expect.objectContaining({
+ method: "GET",
+ url: "https://example.com/api",
+ statusCode: 500,
+ }),
+ );
+ });
+
+ it("should log aborted request errors", async () => {
+ const mockLogger = createMockLogger();
+
+ const abortController = new AbortController();
+ abortController.abort();
+
+ global.fetch = vi.fn().mockRejectedValue(new Error("Aborted"));
+
+ const result = await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ abortSignal: abortController.signal,
+ maxRetries: 0,
+ logging: {
+ level: "error",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(result.ok).toBe(false);
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ "HTTP request was aborted",
+ expect.objectContaining({
+ method: "GET",
+ url: "https://example.com/api",
+ }),
+ );
+ });
+
+ it("should log timeout errors", async () => {
+ const mockLogger = createMockLogger();
+
+ const timeoutError = new Error("Request timeout");
+ timeoutError.name = "AbortError";
+
+ global.fetch = vi.fn().mockRejectedValue(timeoutError);
+
+ const result = await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "error",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(result.ok).toBe(false);
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ "HTTP request timed out",
+ expect.objectContaining({
+ method: "GET",
+ url: "https://example.com/api",
+ timeoutMs: undefined,
+ }),
+ );
+ });
+
+ it("should log unknown errors", async () => {
+ const mockLogger = createMockLogger();
+
+ const unknownError = new Error("Unknown error");
+
+ global.fetch = vi.fn().mockRejectedValue(unknownError);
+
+ const result = await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "error",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(result.ok).toBe(false);
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ "HTTP request failed with error",
+ expect.objectContaining({
+ method: "GET",
+ url: "https://example.com/api",
+ errorMessage: "Unknown error",
+ }),
+ );
+ });
+ });
+
+ describe("Logging with Redaction", () => {
+ it("should redact sensitive data in error logs", async () => {
+ const mockLogger = createMockLogger();
+ mockErrorResponse({ error: "Unauthorized" }, 401, "Unauthorized");
+
+ await fetcherImpl({
+ url: "https://example.com/api?api_key=secret",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "error",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ "HTTP request failed with error status",
+ expect.objectContaining({
+ url: "https://example.com/api?api_key=[REDACTED]",
+ }),
+ );
+ });
+ });
+
+ describe("Different HTTP Methods", () => {
+ it("should log GET requests", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ method: "GET",
+ }),
+ );
+ });
+
+ it("should log POST requests", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse({ data: "test" }, 201, "Created");
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "POST",
+ body: { data: "test" },
+ contentType: "application/json",
+ requestType: "json",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ method: "POST",
+ }),
+ );
+ });
+
+ it("should log PUT requests", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "PUT",
+ body: { data: "test" },
+ contentType: "application/json",
+ requestType: "json",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ method: "PUT",
+ }),
+ );
+ });
+
+ it("should log DELETE requests", async () => {
+ const mockLogger = createMockLogger();
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(null, {
+ status: 200,
+ statusText: "OK",
+ }),
+ );
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "DELETE",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ method: "DELETE",
+ }),
+ );
+ });
+ });
+
+ describe("Status Code Logging", () => {
+ it("should log 2xx success status codes", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse({ data: "test" }, 201, "Created");
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "POST",
+ body: { data: "test" },
+ contentType: "application/json",
+ requestType: "json",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "HTTP request succeeded",
+ expect.objectContaining({
+ statusCode: 201,
+ }),
+ );
+ });
+
+ it("should log 3xx redirect status codes as success", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse({ data: "test" }, 301, "Moved Permanently");
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "HTTP request succeeded",
+ expect.objectContaining({
+ statusCode: 301,
+ }),
+ );
+ });
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/makeRequest.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/makeRequest.test.ts
new file mode 100644
index 000000000000..ea49466a55fc
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/makeRequest.test.ts
@@ -0,0 +1,54 @@
+import type { Mock } from "vitest";
+import { makeRequest } from "../../../src/core/fetcher/makeRequest";
+
+describe("Test makeRequest", () => {
+ const mockPostUrl = "https://httpbin.org/post";
+ const mockGetUrl = "https://httpbin.org/get";
+ const mockHeaders = { "Content-Type": "application/json" };
+ const mockBody = JSON.stringify({ key: "value" });
+
+ let mockFetch: Mock;
+
+ beforeEach(() => {
+ mockFetch = vi.fn();
+ mockFetch.mockResolvedValue(new Response(JSON.stringify({ test: "successful" }), { status: 200 }));
+ });
+
+ it("should handle POST request correctly", async () => {
+ const response = await makeRequest(mockFetch, mockPostUrl, "POST", mockHeaders, mockBody);
+ const responseBody = await response.json();
+ expect(responseBody).toEqual({ test: "successful" });
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const [calledUrl, calledOptions] = mockFetch.mock.calls[0];
+ expect(calledUrl).toBe(mockPostUrl);
+ expect(calledOptions).toEqual(
+ expect.objectContaining({
+ method: "POST",
+ headers: mockHeaders,
+ body: mockBody,
+ credentials: undefined,
+ }),
+ );
+ expect(calledOptions.signal).toBeDefined();
+ expect(calledOptions.signal).toBeInstanceOf(AbortSignal);
+ });
+
+ it("should handle GET request correctly", async () => {
+ const response = await makeRequest(mockFetch, mockGetUrl, "GET", mockHeaders, undefined);
+ const responseBody = await response.json();
+ expect(responseBody).toEqual({ test: "successful" });
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const [calledUrl, calledOptions] = mockFetch.mock.calls[0];
+ expect(calledUrl).toBe(mockGetUrl);
+ expect(calledOptions).toEqual(
+ expect.objectContaining({
+ method: "GET",
+ headers: mockHeaders,
+ body: undefined,
+ credentials: undefined,
+ }),
+ );
+ expect(calledOptions.signal).toBeDefined();
+ expect(calledOptions.signal).toBeInstanceOf(AbortSignal);
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/redacting.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/redacting.test.ts
new file mode 100644
index 000000000000..d599376b9bcf
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/redacting.test.ts
@@ -0,0 +1,1115 @@
+import { fetcherImpl } from "../../../src/core/fetcher/Fetcher";
+
+function createMockLogger() {
+ return {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ };
+}
+
+function mockSuccessResponse(data: unknown = { data: "test" }, status = 200, statusText = "OK") {
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(data), {
+ status,
+ statusText,
+ }),
+ );
+}
+
+describe("Redacting Logic", () => {
+ describe("Header Redaction", () => {
+ it("should redact authorization header", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ headers: { Authorization: "Bearer secret-token-12345" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ headers: expect.toContainHeaders({
+ Authorization: "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact api-key header (case-insensitive)", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ headers: { "X-API-KEY": "secret-api-key" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ headers: expect.toContainHeaders({
+ "X-API-KEY": "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact cookie header", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ headers: { Cookie: "session=abc123; token=xyz789" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ headers: expect.toContainHeaders({
+ Cookie: "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact x-auth-token header", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ headers: { "x-auth-token": "auth-token-12345" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ headers: expect.toContainHeaders({
+ "x-auth-token": "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact proxy-authorization header", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ headers: { "Proxy-Authorization": "Basic credentials" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ headers: expect.toContainHeaders({
+ "Proxy-Authorization": "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact x-csrf-token header", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ headers: { "X-CSRF-Token": "csrf-token-abc" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ headers: expect.toContainHeaders({
+ "X-CSRF-Token": "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact www-authenticate header", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ headers: { "WWW-Authenticate": "Bearer realm=example" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ headers: expect.toContainHeaders({
+ "WWW-Authenticate": "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact x-session-token header", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ headers: { "X-Session-Token": "session-token-xyz" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ headers: expect.toContainHeaders({
+ "X-Session-Token": "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should not redact non-sensitive headers", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ "User-Agent": "Test/1.0",
+ Accept: "application/json",
+ },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ headers: expect.toContainHeaders({
+ "Content-Type": "application/json",
+ "User-Agent": "Test/1.0",
+ Accept: "application/json",
+ }),
+ }),
+ );
+ });
+
+ it("should redact multiple sensitive headers at once", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ headers: {
+ Authorization: "Bearer token",
+ "X-API-Key": "api-key",
+ Cookie: "session=123",
+ "Content-Type": "application/json",
+ },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ headers: expect.toContainHeaders({
+ Authorization: "[REDACTED]",
+ "X-API-Key": "[REDACTED]",
+ Cookie: "[REDACTED]",
+ "Content-Type": "application/json",
+ }),
+ }),
+ );
+ });
+ });
+
+ describe("Response Header Redaction", () => {
+ it("should redact Set-Cookie in response headers", async () => {
+ const mockLogger = createMockLogger();
+
+ const mockHeaders = new Headers();
+ mockHeaders.set("Set-Cookie", "session=abc123; HttpOnly; Secure");
+ mockHeaders.set("Content-Type", "application/json");
+
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ data: "test" }), {
+ status: 200,
+ statusText: "OK",
+ headers: mockHeaders,
+ }),
+ );
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "HTTP request succeeded",
+ expect.objectContaining({
+ responseHeaders: expect.toContainHeaders({
+ "set-cookie": "[REDACTED]",
+ "content-type": "application/json",
+ }),
+ }),
+ );
+ });
+
+ it("should redact authorization in response headers", async () => {
+ const mockLogger = createMockLogger();
+
+ const mockHeaders = new Headers();
+ mockHeaders.set("Authorization", "Bearer token-123");
+ mockHeaders.set("Content-Type", "application/json");
+
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ data: "test" }), {
+ status: 200,
+ statusText: "OK",
+ headers: mockHeaders,
+ }),
+ );
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "HTTP request succeeded",
+ expect.objectContaining({
+ responseHeaders: expect.toContainHeaders({
+ authorization: "[REDACTED]",
+ "content-type": "application/json",
+ }),
+ }),
+ );
+ });
+
+ it("should redact response headers in error responses", async () => {
+ const mockLogger = createMockLogger();
+
+ const mockHeaders = new Headers();
+ mockHeaders.set("WWW-Authenticate", "Bearer realm=example");
+ mockHeaders.set("Content-Type", "application/json");
+
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ error: "Unauthorized" }), {
+ status: 401,
+ statusText: "Unauthorized",
+ headers: mockHeaders,
+ }),
+ );
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "error",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ "HTTP request failed with error status",
+ expect.objectContaining({
+ responseHeaders: expect.toContainHeaders({
+ "www-authenticate": "[REDACTED]",
+ "content-type": "application/json",
+ }),
+ }),
+ );
+ });
+ });
+
+ describe("Query Parameter Redaction", () => {
+ it("should redact api_key query parameter", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ queryParameters: { api_key: "secret-key" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ queryParameters: expect.objectContaining({
+ api_key: "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact token query parameter", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ queryParameters: { token: "secret-token" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ queryParameters: expect.objectContaining({
+ token: "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact access_token query parameter", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ queryParameters: { access_token: "secret-access-token" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ queryParameters: expect.objectContaining({
+ access_token: "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact password query parameter", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ queryParameters: { password: "secret-password" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ queryParameters: expect.objectContaining({
+ password: "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact secret query parameter", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ queryParameters: { secret: "secret-value" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ queryParameters: expect.objectContaining({
+ secret: "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should redact session_id query parameter", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ queryParameters: { session_id: "session-123" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ queryParameters: expect.objectContaining({
+ session_id: "[REDACTED]",
+ }),
+ }),
+ );
+ });
+
+ it("should not redact non-sensitive query parameters", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ queryParameters: {
+ page: "1",
+ limit: "10",
+ sort: "name",
+ },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ queryParameters: expect.objectContaining({
+ page: "1",
+ limit: "10",
+ sort: "name",
+ }),
+ }),
+ );
+ });
+
+ it("should not redact parameters containing 'auth' substring like 'author'", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ queryParameters: {
+ author: "john",
+ authenticate: "false",
+ authorization_level: "user",
+ },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ queryParameters: expect.objectContaining({
+ author: "john",
+ authenticate: "false",
+ authorization_level: "user",
+ }),
+ }),
+ );
+ });
+
+ it("should handle undefined query parameters", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ queryParameters: undefined,
+ }),
+ );
+ });
+
+ it("should redact case-insensitive query parameters", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ queryParameters: { API_KEY: "secret-key", Token: "secret-token" },
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ queryParameters: expect.objectContaining({
+ API_KEY: "[REDACTED]",
+ Token: "[REDACTED]",
+ }),
+ }),
+ );
+ });
+ });
+
+ describe("URL Redaction", () => {
+ it("should redact credentials in URL", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://user:password@example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://[REDACTED]@example.com/api",
+ }),
+ );
+ });
+
+ it("should redact api_key in query string", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?api_key=secret-key&page=1",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?api_key=[REDACTED]&page=1",
+ }),
+ );
+ });
+
+ it("should redact token in query string", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?token=secret-token",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?token=[REDACTED]",
+ }),
+ );
+ });
+
+ it("should redact password in query string", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?username=user&password=secret",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?username=user&password=[REDACTED]",
+ }),
+ );
+ });
+
+ it("should not redact non-sensitive query strings", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?page=1&limit=10&sort=name",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?page=1&limit=10&sort=name",
+ }),
+ );
+ });
+
+ it("should not redact URL parameters containing 'auth' substring like 'author'", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?author=john&authenticate=false&page=1",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?author=john&authenticate=false&page=1",
+ }),
+ );
+ });
+
+ it("should handle URL with fragment", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?token=secret#section",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?token=[REDACTED]#section",
+ }),
+ );
+ });
+
+ it("should redact URL-encoded query parameters", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?api%5Fkey=secret",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?api%5Fkey=[REDACTED]",
+ }),
+ );
+ });
+
+ it("should handle URL without query string", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api",
+ }),
+ );
+ });
+
+ it("should handle empty query string", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?",
+ }),
+ );
+ });
+
+ it("should redact multiple sensitive parameters in URL", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?api_key=secret1&token=secret2&page=1",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?api_key=[REDACTED]&token=[REDACTED]&page=1",
+ }),
+ );
+ });
+
+ it("should redact both credentials and query parameters", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://user:pass@example.com/api?token=secret",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://[REDACTED]@example.com/api?token=[REDACTED]",
+ }),
+ );
+ });
+
+ it("should use fast path for URLs without sensitive keywords", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?page=1&limit=10&sort=name&filter=value",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?page=1&limit=10&sort=name&filter=value",
+ }),
+ );
+ });
+
+ it("should handle query parameter without value", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?flag&token=secret",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?flag&token=[REDACTED]",
+ }),
+ );
+ });
+
+ it("should handle URL with multiple @ symbols in credentials", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://user@example.com:pass@host.com/api",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://[REDACTED]@host.com/api",
+ }),
+ );
+ });
+
+ it("should handle URL with @ in query parameter but not in credentials", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://example.com/api?email=user@example.com",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://example.com/api?email=user@example.com",
+ }),
+ );
+ });
+
+ it("should handle URL with both credentials and @ in path", async () => {
+ const mockLogger = createMockLogger();
+ mockSuccessResponse();
+
+ await fetcherImpl({
+ url: "https://user:pass@example.com/users/@username",
+ method: "GET",
+ responseType: "json",
+ maxRetries: 0,
+ logging: {
+ level: "debug",
+ logger: mockLogger,
+ silent: false,
+ },
+ });
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Making HTTP request",
+ expect.objectContaining({
+ url: "https://[REDACTED]@example.com/users/@username",
+ }),
+ );
+ });
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/requestWithRetries.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/requestWithRetries.test.ts
new file mode 100644
index 000000000000..d22661367f4e
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/requestWithRetries.test.ts
@@ -0,0 +1,230 @@
+import type { Mock, MockInstance } from "vitest";
+import { requestWithRetries } from "../../../src/core/fetcher/requestWithRetries";
+
+describe("requestWithRetries", () => {
+ let mockFetch: Mock;
+ let originalMathRandom: typeof Math.random;
+ let setTimeoutSpy: MockInstance;
+
+ beforeEach(() => {
+ mockFetch = vi.fn();
+ originalMathRandom = Math.random;
+
+ Math.random = vi.fn(() => 0.5);
+
+ vi.useFakeTimers({
+ toFake: [
+ "setTimeout",
+ "clearTimeout",
+ "setInterval",
+ "clearInterval",
+ "setImmediate",
+ "clearImmediate",
+ "Date",
+ "performance",
+ "requestAnimationFrame",
+ "cancelAnimationFrame",
+ "requestIdleCallback",
+ "cancelIdleCallback",
+ ],
+ });
+ });
+
+ afterEach(() => {
+ Math.random = originalMathRandom;
+ vi.clearAllMocks();
+ vi.clearAllTimers();
+ });
+
+ it("should retry on retryable status codes", async () => {
+ setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
+ process.nextTick(callback);
+ return null as any;
+ });
+
+ const retryableStatuses = [408, 429, 500, 502];
+ let callCount = 0;
+
+ mockFetch.mockImplementation(async () => {
+ if (callCount < retryableStatuses.length) {
+ return new Response("", { status: retryableStatuses[callCount++] });
+ }
+ return new Response("", { status: 200 });
+ });
+
+ const responsePromise = requestWithRetries(() => mockFetch(), retryableStatuses.length);
+ await vi.runAllTimersAsync();
+ const response = await responsePromise;
+
+ expect(mockFetch).toHaveBeenCalledTimes(retryableStatuses.length + 1);
+ expect(response.status).toBe(200);
+ });
+
+ it("should respect maxRetries limit", async () => {
+ setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
+ process.nextTick(callback);
+ return null as any;
+ });
+
+ const maxRetries = 2;
+ mockFetch.mockResolvedValue(new Response("", { status: 500 }));
+
+ const responsePromise = requestWithRetries(() => mockFetch(), maxRetries);
+ await vi.runAllTimersAsync();
+ const response = await responsePromise;
+
+ expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1);
+ expect(response.status).toBe(500);
+ });
+
+ it("should not retry on success status codes", async () => {
+ setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
+ process.nextTick(callback);
+ return null as any;
+ });
+
+ const successStatuses = [200, 201, 202];
+
+ for (const status of successStatuses) {
+ mockFetch.mockReset();
+ setTimeoutSpy.mockClear();
+ mockFetch.mockResolvedValueOnce(new Response("", { status }));
+
+ const responsePromise = requestWithRetries(() => mockFetch(), 3);
+ await vi.runAllTimersAsync();
+ await responsePromise;
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(setTimeoutSpy).not.toHaveBeenCalled();
+ }
+ });
+
+ interface RetryHeaderTestCase {
+ description: string;
+ headerName: string;
+ headerValue: string | (() => string);
+ expectedDelayMin: number;
+ expectedDelayMax: number;
+ }
+
+ const retryHeaderTests: RetryHeaderTestCase[] = [
+ {
+ description: "should respect retry-after header with seconds value",
+ headerName: "retry-after",
+ headerValue: "5",
+ expectedDelayMin: 4000,
+ expectedDelayMax: 6000,
+ },
+ {
+ description: "should respect retry-after header with HTTP date value",
+ headerName: "retry-after",
+ headerValue: () => new Date(Date.now() + 3000).toUTCString(),
+ expectedDelayMin: 2000,
+ expectedDelayMax: 4000,
+ },
+ {
+ description: "should respect x-ratelimit-reset header",
+ headerName: "x-ratelimit-reset",
+ headerValue: () => Math.floor((Date.now() + 4000) / 1000).toString(),
+ expectedDelayMin: 3000,
+ expectedDelayMax: 6000,
+ },
+ ];
+
+ retryHeaderTests.forEach(({ description, headerName, headerValue, expectedDelayMin, expectedDelayMax }) => {
+ it(description, async () => {
+ setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
+ process.nextTick(callback);
+ return null as any;
+ });
+
+ const value = typeof headerValue === "function" ? headerValue() : headerValue;
+ mockFetch
+ .mockResolvedValueOnce(
+ new Response("", {
+ status: 429,
+ headers: new Headers({ [headerName]: value }),
+ }),
+ )
+ .mockResolvedValueOnce(new Response("", { status: 200 }));
+
+ const responsePromise = requestWithRetries(() => mockFetch(), 1);
+ await vi.runAllTimersAsync();
+ const response = await responsePromise;
+
+ expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Number));
+ const actualDelay = setTimeoutSpy.mock.calls[0][1];
+ expect(actualDelay).toBeGreaterThan(expectedDelayMin);
+ expect(actualDelay).toBeLessThan(expectedDelayMax);
+ expect(response.status).toBe(200);
+ });
+ });
+
+ it("should apply correct exponential backoff with jitter", async () => {
+ setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
+ process.nextTick(callback);
+ return null as any;
+ });
+
+ mockFetch.mockResolvedValue(new Response("", { status: 500 }));
+ const maxRetries = 3;
+ const expectedDelays = [1000, 2000, 4000];
+
+ const responsePromise = requestWithRetries(() => mockFetch(), maxRetries);
+ await vi.runAllTimersAsync();
+ await responsePromise;
+
+ expect(setTimeoutSpy).toHaveBeenCalledTimes(expectedDelays.length);
+
+ expectedDelays.forEach((delay, index) => {
+ expect(setTimeoutSpy).toHaveBeenNthCalledWith(index + 1, expect.any(Function), delay);
+ });
+
+ expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1);
+ });
+
+ it("should handle concurrent retries independently", async () => {
+ setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
+ process.nextTick(callback);
+ return null as any;
+ });
+
+ mockFetch
+ .mockResolvedValueOnce(new Response("", { status: 500 }))
+ .mockResolvedValueOnce(new Response("", { status: 500 }))
+ .mockResolvedValueOnce(new Response("", { status: 200 }))
+ .mockResolvedValueOnce(new Response("", { status: 200 }));
+
+ const promise1 = requestWithRetries(() => mockFetch(), 1);
+ const promise2 = requestWithRetries(() => mockFetch(), 1);
+
+ await vi.runAllTimersAsync();
+ const [response1, response2] = await Promise.all([promise1, promise2]);
+
+ expect(response1.status).toBe(200);
+ expect(response2.status).toBe(200);
+ });
+
+ it("should cap delay at MAX_RETRY_DELAY for large header values", async () => {
+ setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
+ process.nextTick(callback);
+ return null as any;
+ });
+
+ mockFetch
+ .mockResolvedValueOnce(
+ new Response("", {
+ status: 429,
+ headers: new Headers({ "retry-after": "120" }), // 120 seconds = 120000ms > MAX_RETRY_DELAY (60000ms)
+ }),
+ )
+ .mockResolvedValueOnce(new Response("", { status: 200 }));
+
+ const responsePromise = requestWithRetries(() => mockFetch(), 1);
+ await vi.runAllTimersAsync();
+ const response = await responsePromise;
+
+ expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60000);
+ expect(response.status).toBe(200);
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/signals.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/signals.test.ts
new file mode 100644
index 000000000000..d7b6d1e63caa
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/signals.test.ts
@@ -0,0 +1,69 @@
+import { anySignal, getTimeoutSignal } from "../../../src/core/fetcher/signals";
+
+describe("Test getTimeoutSignal", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("should return an object with signal and abortId", () => {
+ const { signal, abortId } = getTimeoutSignal(1000);
+
+ expect(signal).toBeDefined();
+ expect(abortId).toBeDefined();
+ expect(signal).toBeInstanceOf(AbortSignal);
+ expect(signal.aborted).toBe(false);
+ });
+
+ it("should create a signal that aborts after the specified timeout", () => {
+ const timeoutMs = 5000;
+ const { signal } = getTimeoutSignal(timeoutMs);
+
+ expect(signal.aborted).toBe(false);
+
+ vi.advanceTimersByTime(timeoutMs - 1);
+ expect(signal.aborted).toBe(false);
+
+ vi.advanceTimersByTime(1);
+ expect(signal.aborted).toBe(true);
+ });
+});
+
+describe("Test anySignal", () => {
+ it("should return an AbortSignal", () => {
+ const signal = anySignal(new AbortController().signal);
+ expect(signal).toBeInstanceOf(AbortSignal);
+ });
+
+ it("should abort when any of the input signals is aborted", () => {
+ const controller1 = new AbortController();
+ const controller2 = new AbortController();
+ const signal = anySignal(controller1.signal, controller2.signal);
+
+ expect(signal.aborted).toBe(false);
+ controller1.abort();
+ expect(signal.aborted).toBe(true);
+ });
+
+ it("should handle an array of signals", () => {
+ const controller1 = new AbortController();
+ const controller2 = new AbortController();
+ const signal = anySignal([controller1.signal, controller2.signal]);
+
+ expect(signal.aborted).toBe(false);
+ controller2.abort();
+ expect(signal.aborted).toBe(true);
+ });
+
+ it("should abort immediately if one of the input signals is already aborted", () => {
+ const controller1 = new AbortController();
+ const controller2 = new AbortController();
+ controller1.abort();
+
+ const signal = anySignal(controller1.signal, controller2.signal);
+ expect(signal.aborted).toBe(true);
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/test-file.txt b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/test-file.txt
new file mode 100644
index 000000000000..c66d471e359c
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/fetcher/test-file.txt
@@ -0,0 +1 @@
+This is a test file!
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/logging/logger.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/logging/logger.test.ts
new file mode 100644
index 000000000000..2e0b5fe5040c
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/logging/logger.test.ts
@@ -0,0 +1,454 @@
+import { ConsoleLogger, createLogger, Logger, LogLevel } from "../../../src/core/logging/logger";
+
+function createMockLogger() {
+ return {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ };
+}
+
+describe("Logger", () => {
+ describe("LogLevel", () => {
+ it("should have correct log levels", () => {
+ expect(LogLevel.Debug).toBe("debug");
+ expect(LogLevel.Info).toBe("info");
+ expect(LogLevel.Warn).toBe("warn");
+ expect(LogLevel.Error).toBe("error");
+ });
+ });
+
+ describe("ConsoleLogger", () => {
+ let consoleLogger: ConsoleLogger;
+ let consoleSpy: {
+ debug: ReturnType;
+ info: ReturnType;
+ warn: ReturnType;
+ error: ReturnType;
+ };
+
+ beforeEach(() => {
+ consoleLogger = new ConsoleLogger();
+ consoleSpy = {
+ debug: vi.spyOn(console, "debug").mockImplementation(() => {}),
+ info: vi.spyOn(console, "info").mockImplementation(() => {}),
+ warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
+ error: vi.spyOn(console, "error").mockImplementation(() => {}),
+ };
+ });
+
+ afterEach(() => {
+ consoleSpy.debug.mockRestore();
+ consoleSpy.info.mockRestore();
+ consoleSpy.warn.mockRestore();
+ consoleSpy.error.mockRestore();
+ });
+
+ it("should log debug messages", () => {
+ consoleLogger.debug("debug message", { data: "test" });
+ expect(consoleSpy.debug).toHaveBeenCalledWith("debug message", { data: "test" });
+ });
+
+ it("should log info messages", () => {
+ consoleLogger.info("info message", { data: "test" });
+ expect(consoleSpy.info).toHaveBeenCalledWith("info message", { data: "test" });
+ });
+
+ it("should log warn messages", () => {
+ consoleLogger.warn("warn message", { data: "test" });
+ expect(consoleSpy.warn).toHaveBeenCalledWith("warn message", { data: "test" });
+ });
+
+ it("should log error messages", () => {
+ consoleLogger.error("error message", { data: "test" });
+ expect(consoleSpy.error).toHaveBeenCalledWith("error message", { data: "test" });
+ });
+
+ it("should handle multiple arguments", () => {
+ consoleLogger.debug("message", "arg1", "arg2", { key: "value" });
+ expect(consoleSpy.debug).toHaveBeenCalledWith("message", "arg1", "arg2", { key: "value" });
+ });
+ });
+
+ describe("Logger with level filtering", () => {
+ let mockLogger: {
+ debug: ReturnType;
+ info: ReturnType;
+ warn: ReturnType;
+ error: ReturnType;
+ };
+
+ beforeEach(() => {
+ mockLogger = createMockLogger();
+ });
+
+ describe("Debug level", () => {
+ it("should log all levels when set to debug", () => {
+ const logger = new Logger({
+ level: LogLevel.Debug,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ logger.debug("debug");
+ logger.info("info");
+ logger.warn("warn");
+ logger.error("error");
+
+ expect(mockLogger.debug).toHaveBeenCalledWith("debug");
+ expect(mockLogger.info).toHaveBeenCalledWith("info");
+ expect(mockLogger.warn).toHaveBeenCalledWith("warn");
+ expect(mockLogger.error).toHaveBeenCalledWith("error");
+ });
+
+ it("should report correct level checks", () => {
+ const logger = new Logger({
+ level: LogLevel.Debug,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ expect(logger.isDebug()).toBe(true);
+ expect(logger.isInfo()).toBe(true);
+ expect(logger.isWarn()).toBe(true);
+ expect(logger.isError()).toBe(true);
+ });
+ });
+
+ describe("Info level", () => {
+ it("should log info, warn, and error when set to info", () => {
+ const logger = new Logger({
+ level: LogLevel.Info,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ logger.debug("debug");
+ logger.info("info");
+ logger.warn("warn");
+ logger.error("error");
+
+ expect(mockLogger.debug).not.toHaveBeenCalled();
+ expect(mockLogger.info).toHaveBeenCalledWith("info");
+ expect(mockLogger.warn).toHaveBeenCalledWith("warn");
+ expect(mockLogger.error).toHaveBeenCalledWith("error");
+ });
+
+ it("should report correct level checks", () => {
+ const logger = new Logger({
+ level: LogLevel.Info,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ expect(logger.isDebug()).toBe(false);
+ expect(logger.isInfo()).toBe(true);
+ expect(logger.isWarn()).toBe(true);
+ expect(logger.isError()).toBe(true);
+ });
+ });
+
+ describe("Warn level", () => {
+ it("should log warn and error when set to warn", () => {
+ const logger = new Logger({
+ level: LogLevel.Warn,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ logger.debug("debug");
+ logger.info("info");
+ logger.warn("warn");
+ logger.error("error");
+
+ expect(mockLogger.debug).not.toHaveBeenCalled();
+ expect(mockLogger.info).not.toHaveBeenCalled();
+ expect(mockLogger.warn).toHaveBeenCalledWith("warn");
+ expect(mockLogger.error).toHaveBeenCalledWith("error");
+ });
+
+ it("should report correct level checks", () => {
+ const logger = new Logger({
+ level: LogLevel.Warn,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ expect(logger.isDebug()).toBe(false);
+ expect(logger.isInfo()).toBe(false);
+ expect(logger.isWarn()).toBe(true);
+ expect(logger.isError()).toBe(true);
+ });
+ });
+
+ describe("Error level", () => {
+ it("should only log error when set to error", () => {
+ const logger = new Logger({
+ level: LogLevel.Error,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ logger.debug("debug");
+ logger.info("info");
+ logger.warn("warn");
+ logger.error("error");
+
+ expect(mockLogger.debug).not.toHaveBeenCalled();
+ expect(mockLogger.info).not.toHaveBeenCalled();
+ expect(mockLogger.warn).not.toHaveBeenCalled();
+ expect(mockLogger.error).toHaveBeenCalledWith("error");
+ });
+
+ it("should report correct level checks", () => {
+ const logger = new Logger({
+ level: LogLevel.Error,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ expect(logger.isDebug()).toBe(false);
+ expect(logger.isInfo()).toBe(false);
+ expect(logger.isWarn()).toBe(false);
+ expect(logger.isError()).toBe(true);
+ });
+ });
+
+ describe("Silent mode", () => {
+ it("should not log anything when silent is true", () => {
+ const logger = new Logger({
+ level: LogLevel.Debug,
+ logger: mockLogger,
+ silent: true,
+ });
+
+ logger.debug("debug");
+ logger.info("info");
+ logger.warn("warn");
+ logger.error("error");
+
+ expect(mockLogger.debug).not.toHaveBeenCalled();
+ expect(mockLogger.info).not.toHaveBeenCalled();
+ expect(mockLogger.warn).not.toHaveBeenCalled();
+ expect(mockLogger.error).not.toHaveBeenCalled();
+ });
+
+ it("should report all level checks as false when silent", () => {
+ const logger = new Logger({
+ level: LogLevel.Debug,
+ logger: mockLogger,
+ silent: true,
+ });
+
+ expect(logger.isDebug()).toBe(false);
+ expect(logger.isInfo()).toBe(false);
+ expect(logger.isWarn()).toBe(false);
+ expect(logger.isError()).toBe(false);
+ });
+ });
+
+ describe("shouldLog", () => {
+ it("should correctly determine if level should be logged", () => {
+ const logger = new Logger({
+ level: LogLevel.Info,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ expect(logger.shouldLog(LogLevel.Debug)).toBe(false);
+ expect(logger.shouldLog(LogLevel.Info)).toBe(true);
+ expect(logger.shouldLog(LogLevel.Warn)).toBe(true);
+ expect(logger.shouldLog(LogLevel.Error)).toBe(true);
+ });
+
+ it("should return false for all levels when silent", () => {
+ const logger = new Logger({
+ level: LogLevel.Debug,
+ logger: mockLogger,
+ silent: true,
+ });
+
+ expect(logger.shouldLog(LogLevel.Debug)).toBe(false);
+ expect(logger.shouldLog(LogLevel.Info)).toBe(false);
+ expect(logger.shouldLog(LogLevel.Warn)).toBe(false);
+ expect(logger.shouldLog(LogLevel.Error)).toBe(false);
+ });
+ });
+
+ describe("Multiple arguments", () => {
+ it("should pass multiple arguments to logger", () => {
+ const logger = new Logger({
+ level: LogLevel.Debug,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ logger.debug("message", "arg1", { key: "value" }, 123);
+ expect(mockLogger.debug).toHaveBeenCalledWith("message", "arg1", { key: "value" }, 123);
+ });
+ });
+ });
+
+ describe("createLogger", () => {
+ it("should return default logger when no config provided", () => {
+ const logger = createLogger();
+ expect(logger).toBeInstanceOf(Logger);
+ });
+
+ it("should return same logger instance when Logger is passed", () => {
+ const customLogger = new Logger({
+ level: LogLevel.Debug,
+ logger: new ConsoleLogger(),
+ silent: false,
+ });
+
+ const result = createLogger(customLogger);
+ expect(result).toBe(customLogger);
+ });
+
+ it("should create logger with custom config", () => {
+ const mockLogger = createMockLogger();
+
+ const logger = createLogger({
+ level: LogLevel.Warn,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ expect(logger).toBeInstanceOf(Logger);
+ logger.warn("test");
+ expect(mockLogger.warn).toHaveBeenCalledWith("test");
+ });
+
+ it("should use default values for missing config", () => {
+ const logger = createLogger({});
+ expect(logger).toBeInstanceOf(Logger);
+ });
+
+ it("should override default level", () => {
+ const mockLogger = createMockLogger();
+
+ const logger = createLogger({
+ level: LogLevel.Debug,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ logger.debug("test");
+ expect(mockLogger.debug).toHaveBeenCalledWith("test");
+ });
+
+ it("should override default silent mode", () => {
+ const mockLogger = createMockLogger();
+
+ const logger = createLogger({
+ logger: mockLogger,
+ silent: false,
+ });
+
+ logger.info("test");
+ expect(mockLogger.info).toHaveBeenCalledWith("test");
+ });
+
+ it("should use provided logger implementation", () => {
+ const customLogger = createMockLogger();
+
+ const logger = createLogger({
+ logger: customLogger,
+ level: LogLevel.Debug,
+ silent: false,
+ });
+
+ logger.debug("test");
+ expect(customLogger.debug).toHaveBeenCalledWith("test");
+ });
+
+ it("should default to silent: true", () => {
+ const mockLogger = createMockLogger();
+
+ const logger = createLogger({
+ logger: mockLogger,
+ level: LogLevel.Debug,
+ });
+
+ logger.debug("test");
+ expect(mockLogger.debug).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Default logger", () => {
+ it("should have silent: true by default", () => {
+ const logger = createLogger();
+ expect(logger.shouldLog(LogLevel.Info)).toBe(false);
+ });
+
+ it("should not log when using default logger", () => {
+ const logger = createLogger();
+
+ logger.info("test");
+ expect(logger.isInfo()).toBe(false);
+ });
+ });
+
+ describe("Edge cases", () => {
+ it("should handle empty message", () => {
+ const mockLogger = createMockLogger();
+
+ const logger = new Logger({
+ level: LogLevel.Debug,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ logger.debug("");
+ expect(mockLogger.debug).toHaveBeenCalledWith("");
+ });
+
+ it("should handle no arguments", () => {
+ const mockLogger = createMockLogger();
+
+ const logger = new Logger({
+ level: LogLevel.Debug,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ logger.debug("message");
+ expect(mockLogger.debug).toHaveBeenCalledWith("message");
+ });
+
+ it("should handle complex objects", () => {
+ const mockLogger = createMockLogger();
+
+ const logger = new Logger({
+ level: LogLevel.Debug,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ const complexObject = {
+ nested: { key: "value" },
+ array: [1, 2, 3],
+ fn: () => "test",
+ };
+
+ logger.debug("message", complexObject);
+ expect(mockLogger.debug).toHaveBeenCalledWith("message", complexObject);
+ });
+
+ it("should handle errors as arguments", () => {
+ const mockLogger = createMockLogger();
+
+ const logger = new Logger({
+ level: LogLevel.Error,
+ logger: mockLogger,
+ silent: false,
+ });
+
+ const error = new Error("Test error");
+ logger.error("Error occurred", error);
+ expect(mockLogger.error).toHaveBeenCalledWith("Error occurred", error);
+ });
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/url/join.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/url/join.test.ts
new file mode 100644
index 000000000000..123488f084ea
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/url/join.test.ts
@@ -0,0 +1,284 @@
+import { join } from "../../../src/core/url/index";
+
+describe("join", () => {
+ interface TestCase {
+ description: string;
+ base: string;
+ segments: string[];
+ expected: string;
+ }
+
+ describe("basic functionality", () => {
+ const basicTests: TestCase[] = [
+ { description: "should return empty string for empty base", base: "", segments: [], expected: "" },
+ {
+ description: "should return empty string for empty base with path",
+ base: "",
+ segments: ["path"],
+ expected: "",
+ },
+ {
+ description: "should handle single segment",
+ base: "base",
+ segments: ["segment"],
+ expected: "base/segment",
+ },
+ {
+ description: "should handle single segment with trailing slash on base",
+ base: "base/",
+ segments: ["segment"],
+ expected: "base/segment",
+ },
+ {
+ description: "should handle single segment with leading slash",
+ base: "base",
+ segments: ["/segment"],
+ expected: "base/segment",
+ },
+ {
+ description: "should handle single segment with both slashes",
+ base: "base/",
+ segments: ["/segment"],
+ expected: "base/segment",
+ },
+ {
+ description: "should handle multiple segments",
+ base: "base",
+ segments: ["path1", "path2", "path3"],
+ expected: "base/path1/path2/path3",
+ },
+ {
+ description: "should handle multiple segments with slashes",
+ base: "base/",
+ segments: ["/path1/", "/path2/", "/path3/"],
+ expected: "base/path1/path2/path3/",
+ },
+ ];
+
+ basicTests.forEach(({ description, base, segments, expected }) => {
+ it(description, () => {
+ expect(join(base, ...segments)).toBe(expected);
+ });
+ });
+ });
+
+ describe("URL handling", () => {
+ const urlTests: TestCase[] = [
+ {
+ description: "should handle absolute URLs",
+ base: "https://example.com",
+ segments: ["api", "v1"],
+ expected: "https://example.com/api/v1",
+ },
+ {
+ description: "should handle absolute URLs with slashes",
+ base: "https://example.com/",
+ segments: ["/api/", "/v1/"],
+ expected: "https://example.com/api/v1/",
+ },
+ {
+ description: "should handle absolute URLs with base path",
+ base: "https://example.com/base",
+ segments: ["api", "v1"],
+ expected: "https://example.com/base/api/v1",
+ },
+ {
+ description: "should preserve URL query parameters",
+ base: "https://example.com?query=1",
+ segments: ["api"],
+ expected: "https://example.com/api?query=1",
+ },
+ {
+ description: "should preserve URL fragments",
+ base: "https://example.com#fragment",
+ segments: ["api"],
+ expected: "https://example.com/api#fragment",
+ },
+ {
+ description: "should preserve URL query and fragments",
+ base: "https://example.com?query=1#fragment",
+ segments: ["api"],
+ expected: "https://example.com/api?query=1#fragment",
+ },
+ {
+ description: "should handle http protocol",
+ base: "http://example.com",
+ segments: ["api"],
+ expected: "http://example.com/api",
+ },
+ {
+ description: "should handle ftp protocol",
+ base: "ftp://example.com",
+ segments: ["files"],
+ expected: "ftp://example.com/files",
+ },
+ {
+ description: "should handle ws protocol",
+ base: "ws://example.com",
+ segments: ["socket"],
+ expected: "ws://example.com/socket",
+ },
+ {
+ description: "should fallback to path joining for malformed URLs",
+ base: "not-a-url://",
+ segments: ["path"],
+ expected: "not-a-url:///path",
+ },
+ ];
+
+ urlTests.forEach(({ description, base, segments, expected }) => {
+ it(description, () => {
+ expect(join(base, ...segments)).toBe(expected);
+ });
+ });
+ });
+
+ describe("edge cases", () => {
+ const edgeCaseTests: TestCase[] = [
+ {
+ description: "should handle empty segments",
+ base: "base",
+ segments: ["", "path"],
+ expected: "base/path",
+ },
+ {
+ description: "should handle null segments",
+ base: "base",
+ segments: [null as any, "path"],
+ expected: "base/path",
+ },
+ {
+ description: "should handle undefined segments",
+ base: "base",
+ segments: [undefined as any, "path"],
+ expected: "base/path",
+ },
+ {
+ description: "should handle segments with only single slash",
+ base: "base",
+ segments: ["/", "path"],
+ expected: "base/path",
+ },
+ {
+ description: "should handle segments with only double slash",
+ base: "base",
+ segments: ["//", "path"],
+ expected: "base/path",
+ },
+ {
+ description: "should handle base paths with trailing slashes",
+ base: "base/",
+ segments: ["path"],
+ expected: "base/path",
+ },
+ {
+ description: "should handle complex nested paths",
+ base: "api/v1/",
+ segments: ["/users/", "/123/", "/profile"],
+ expected: "api/v1/users/123/profile",
+ },
+ ];
+
+ edgeCaseTests.forEach(({ description, base, segments, expected }) => {
+ it(description, () => {
+ expect(join(base, ...segments)).toBe(expected);
+ });
+ });
+ });
+
+ describe("real-world scenarios", () => {
+ const realWorldTests: TestCase[] = [
+ {
+ description: "should handle API endpoint construction",
+ base: "https://api.example.com/v1",
+ segments: ["users", "123", "posts"],
+ expected: "https://api.example.com/v1/users/123/posts",
+ },
+ {
+ description: "should handle file path construction",
+ base: "/var/www",
+ segments: ["html", "assets", "images"],
+ expected: "/var/www/html/assets/images",
+ },
+ {
+ description: "should handle relative path construction",
+ base: "../parent",
+ segments: ["child", "grandchild"],
+ expected: "../parent/child/grandchild",
+ },
+ {
+ description: "should handle Windows-style paths",
+ base: "C:\\Users",
+ segments: ["Documents", "file.txt"],
+ expected: "C:\\Users/Documents/file.txt",
+ },
+ ];
+
+ realWorldTests.forEach(({ description, base, segments, expected }) => {
+ it(description, () => {
+ expect(join(base, ...segments)).toBe(expected);
+ });
+ });
+ });
+
+ describe("performance scenarios", () => {
+ it("should handle many segments efficiently", () => {
+ const segments = Array(100).fill("segment");
+ const result = join("base", ...segments);
+ expect(result).toBe(`base/${segments.join("/")}`);
+ });
+
+ it("should handle long URLs", () => {
+ const longPath = "a".repeat(1000);
+ expect(join("https://example.com", longPath)).toBe(`https://example.com/${longPath}`);
+ });
+ });
+
+ describe("trailing slash preservation", () => {
+ const trailingSlashTests: TestCase[] = [
+ {
+ description:
+ "should preserve trailing slash on final result when base has trailing slash and no segments",
+ base: "https://api.example.com/",
+ segments: [],
+ expected: "https://api.example.com/",
+ },
+ {
+ description: "should preserve trailing slash on v1 path",
+ base: "https://api.example.com/v1/",
+ segments: [],
+ expected: "https://api.example.com/v1/",
+ },
+ {
+ description: "should preserve trailing slash when last segment has trailing slash",
+ base: "https://api.example.com",
+ segments: ["users/"],
+ expected: "https://api.example.com/users/",
+ },
+ {
+ description: "should preserve trailing slash with relative path",
+ base: "api/v1",
+ segments: ["users/"],
+ expected: "api/v1/users/",
+ },
+ {
+ description: "should preserve trailing slash with multiple segments",
+ base: "https://api.example.com",
+ segments: ["v1", "collections/"],
+ expected: "https://api.example.com/v1/collections/",
+ },
+ {
+ description: "should preserve trailing slash with base path",
+ base: "base",
+ segments: ["path1", "path2/"],
+ expected: "base/path1/path2/",
+ },
+ ];
+
+ trailingSlashTests.forEach(({ description, base, segments, expected }) => {
+ it(description, () => {
+ expect(join(base, ...segments)).toBe(expected);
+ });
+ });
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/unit/url/qs.test.ts b/seed/ts-sdk/path-body-property-collision/tests/unit/url/qs.test.ts
new file mode 100644
index 000000000000..42cdffb9e5ea
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/unit/url/qs.test.ts
@@ -0,0 +1,278 @@
+import { toQueryString } from "../../../src/core/url/index";
+
+describe("Test qs toQueryString", () => {
+ interface BasicTestCase {
+ description: string;
+ input: any;
+ expected: string;
+ }
+
+ describe("Basic functionality", () => {
+ const basicTests: BasicTestCase[] = [
+ { description: "should return empty string for null", input: null, expected: "" },
+ { description: "should return empty string for undefined", input: undefined, expected: "" },
+ { description: "should return empty string for string primitive", input: "hello", expected: "" },
+ { description: "should return empty string for number primitive", input: 42, expected: "" },
+ { description: "should return empty string for true boolean", input: true, expected: "" },
+ { description: "should return empty string for false boolean", input: false, expected: "" },
+ { description: "should handle empty objects", input: {}, expected: "" },
+ {
+ description: "should handle simple key-value pairs",
+ input: { name: "John", age: 30 },
+ expected: "name=John&age=30",
+ },
+ ];
+
+ basicTests.forEach(({ description, input, expected }) => {
+ it(description, () => {
+ expect(toQueryString(input)).toBe(expected);
+ });
+ });
+ });
+
+ describe("Array handling", () => {
+ interface ArrayTestCase {
+ description: string;
+ input: any;
+ options?: { arrayFormat?: "repeat" | "indices" };
+ expected: string;
+ }
+
+ const arrayTests: ArrayTestCase[] = [
+ {
+ description: "should handle arrays with indices format (default)",
+ input: { items: ["a", "b", "c"] },
+ expected: "items%5B0%5D=a&items%5B1%5D=b&items%5B2%5D=c",
+ },
+ {
+ description: "should handle arrays with repeat format",
+ input: { items: ["a", "b", "c"] },
+ options: { arrayFormat: "repeat" },
+ expected: "items=a&items=b&items=c",
+ },
+ {
+ description: "should handle empty arrays",
+ input: { items: [] },
+ expected: "",
+ },
+ {
+ description: "should handle arrays with mixed types",
+ input: { mixed: ["string", 42, true, false] },
+ expected: "mixed%5B0%5D=string&mixed%5B1%5D=42&mixed%5B2%5D=true&mixed%5B3%5D=false",
+ },
+ {
+ description: "should handle arrays with objects",
+ input: { users: [{ name: "John" }, { name: "Jane" }] },
+ expected: "users%5B0%5D%5Bname%5D=John&users%5B1%5D%5Bname%5D=Jane",
+ },
+ {
+ description: "should handle arrays with objects in repeat format",
+ input: { users: [{ name: "John" }, { name: "Jane" }] },
+ options: { arrayFormat: "repeat" },
+ expected: "users%5Bname%5D=John&users%5Bname%5D=Jane",
+ },
+ ];
+
+ arrayTests.forEach(({ description, input, options, expected }) => {
+ it(description, () => {
+ expect(toQueryString(input, options)).toBe(expected);
+ });
+ });
+ });
+
+ describe("Nested objects", () => {
+ const nestedTests: BasicTestCase[] = [
+ {
+ description: "should handle nested objects",
+ input: { user: { name: "John", age: 30 } },
+ expected: "user%5Bname%5D=John&user%5Bage%5D=30",
+ },
+ {
+ description: "should handle deeply nested objects",
+ input: { user: { profile: { name: "John", settings: { theme: "dark" } } } },
+ expected: "user%5Bprofile%5D%5Bname%5D=John&user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark",
+ },
+ {
+ description: "should handle empty nested objects",
+ input: { user: {} },
+ expected: "",
+ },
+ ];
+
+ nestedTests.forEach(({ description, input, expected }) => {
+ it(description, () => {
+ expect(toQueryString(input)).toBe(expected);
+ });
+ });
+ });
+
+ describe("Encoding", () => {
+ interface EncodingTestCase {
+ description: string;
+ input: any;
+ options?: { encode?: boolean };
+ expected: string;
+ }
+
+ const encodingTests: EncodingTestCase[] = [
+ {
+ description: "should encode by default",
+ input: { name: "John Doe", email: "john@example.com" },
+ expected: "name=John%20Doe&email=john%40example.com",
+ },
+ {
+ description: "should not encode when encode is false",
+ input: { name: "John Doe", email: "john@example.com" },
+ options: { encode: false },
+ expected: "name=John Doe&email=john@example.com",
+ },
+ {
+ description: "should encode special characters in keys",
+ input: { "user name": "John", "email[primary]": "john@example.com" },
+ expected: "user%20name=John&email%5Bprimary%5D=john%40example.com",
+ },
+ {
+ description: "should not encode special characters in keys when encode is false",
+ input: { "user name": "John", "email[primary]": "john@example.com" },
+ options: { encode: false },
+ expected: "user name=John&email[primary]=john@example.com",
+ },
+ ];
+
+ encodingTests.forEach(({ description, input, options, expected }) => {
+ it(description, () => {
+ expect(toQueryString(input, options)).toBe(expected);
+ });
+ });
+ });
+
+ describe("Mixed scenarios", () => {
+ interface MixedTestCase {
+ description: string;
+ input: any;
+ options?: { arrayFormat?: "repeat" | "indices" };
+ expected: string;
+ }
+
+ const mixedTests: MixedTestCase[] = [
+ {
+ description: "should handle complex nested structures",
+ input: {
+ filters: {
+ status: ["active", "pending"],
+ category: {
+ type: "electronics",
+ subcategories: ["phones", "laptops"],
+ },
+ },
+ sort: { field: "name", direction: "asc" },
+ },
+ expected:
+ "filters%5Bstatus%5D%5B0%5D=active&filters%5Bstatus%5D%5B1%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D%5B0%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D%5B1%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc",
+ },
+ {
+ description: "should handle complex nested structures with repeat format",
+ input: {
+ filters: {
+ status: ["active", "pending"],
+ category: {
+ type: "electronics",
+ subcategories: ["phones", "laptops"],
+ },
+ },
+ sort: { field: "name", direction: "asc" },
+ },
+ options: { arrayFormat: "repeat" },
+ expected:
+ "filters%5Bstatus%5D=active&filters%5Bstatus%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc",
+ },
+ {
+ description: "should handle arrays with null/undefined values",
+ input: { items: ["a", null, "c", undefined, "e"] },
+ expected: "items%5B0%5D=a&items%5B1%5D=&items%5B2%5D=c&items%5B4%5D=e",
+ },
+ {
+ description: "should handle objects with null/undefined values",
+ input: { name: "John", age: null, email: undefined, active: true },
+ expected: "name=John&age=&active=true",
+ },
+ ];
+
+ mixedTests.forEach(({ description, input, options, expected }) => {
+ it(description, () => {
+ expect(toQueryString(input, options)).toBe(expected);
+ });
+ });
+ });
+
+ describe("Edge cases", () => {
+ const edgeCaseTests: BasicTestCase[] = [
+ {
+ description: "should handle numeric keys",
+ input: { "0": "zero", "1": "one" },
+ expected: "0=zero&1=one",
+ },
+ {
+ description: "should handle boolean values in objects",
+ input: { enabled: true, disabled: false },
+ expected: "enabled=true&disabled=false",
+ },
+ {
+ description: "should handle empty strings",
+ input: { name: "", description: "test" },
+ expected: "name=&description=test",
+ },
+ {
+ description: "should handle zero values",
+ input: { count: 0, price: 0.0 },
+ expected: "count=0&price=0",
+ },
+ {
+ description: "should handle arrays with empty strings",
+ input: { items: ["a", "", "c"] },
+ expected: "items%5B0%5D=a&items%5B1%5D=&items%5B2%5D=c",
+ },
+ ];
+
+ edgeCaseTests.forEach(({ description, input, expected }) => {
+ it(description, () => {
+ expect(toQueryString(input)).toBe(expected);
+ });
+ });
+ });
+
+ describe("Options combinations", () => {
+ interface OptionsTestCase {
+ description: string;
+ input: any;
+ options?: { arrayFormat?: "repeat" | "indices"; encode?: boolean };
+ expected: string;
+ }
+
+ const optionsTests: OptionsTestCase[] = [
+ {
+ description: "should respect both arrayFormat and encode options",
+ input: { items: ["a & b", "c & d"] },
+ options: { arrayFormat: "repeat", encode: false },
+ expected: "items=a & b&items=c & d",
+ },
+ {
+ description: "should use default options when none provided",
+ input: { items: ["a", "b"] },
+ expected: "items%5B0%5D=a&items%5B1%5D=b",
+ },
+ {
+ description: "should merge provided options with defaults",
+ input: { items: ["a", "b"], name: "John Doe" },
+ options: { encode: false },
+ expected: "items[0]=a&items[1]=b&name=John Doe",
+ },
+ ];
+
+ optionsTests.forEach(({ description, input, options, expected }) => {
+ it(description, () => {
+ expect(toQueryString(input, options)).toBe(expected);
+ });
+ });
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tests/wire/.gitkeep b/seed/ts-sdk/path-body-property-collision/tests/wire/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/seed/ts-sdk/path-body-property-collision/tests/wire/main.test.ts b/seed/ts-sdk/path-body-property-collision/tests/wire/main.test.ts
new file mode 100644
index 000000000000..58a5295d4a04
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tests/wire/main.test.ts
@@ -0,0 +1,29 @@
+// This file was auto-generated by Fern from our API Definition.
+
+import { SeedApiClient } from "../../src/Client";
+import { mockServerPool } from "../mock-server/MockServerPool";
+
+describe("SeedApiClient", () => {
+ test("toggleResource", async () => {
+ const server = mockServerPool.createServer();
+ const client = new SeedApiClient({ maxRetries: 0, environment: server.baseUrl });
+ const rawRequestBody = { key: "key", enabled: true };
+ const rawResponseBody = { success: true };
+ server
+ .mockEndpoint()
+ .post("/resources/key/toggle")
+ .jsonBody(rawRequestBody)
+ .respondWith()
+ .statusCode(200)
+ .jsonBody(rawResponseBody)
+ .build();
+
+ const response = await client.toggleResource({
+ key: "key",
+ enabled: true,
+ });
+ expect(response).toEqual({
+ success: true,
+ });
+ });
+});
diff --git a/seed/ts-sdk/path-body-property-collision/tsconfig.base.json b/seed/ts-sdk/path-body-property-collision/tsconfig.base.json
new file mode 100644
index 000000000000..d7627675de20
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tsconfig.base.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "extendedDiagnostics": true,
+ "strict": true,
+ "target": "ES6",
+ "moduleResolution": "node",
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "baseUrl": "src",
+ "isolatedModules": true,
+ "isolatedDeclarations": true
+ },
+ "include": ["src"],
+ "exclude": []
+}
diff --git a/seed/ts-sdk/path-body-property-collision/tsconfig.cjs.json b/seed/ts-sdk/path-body-property-collision/tsconfig.cjs.json
new file mode 100644
index 000000000000..5c11446f5984
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tsconfig.cjs.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "module": "CommonJS",
+ "outDir": "dist/cjs"
+ },
+ "include": ["src"],
+ "exclude": []
+}
diff --git a/seed/ts-sdk/path-body-property-collision/tsconfig.esm.json b/seed/ts-sdk/path-body-property-collision/tsconfig.esm.json
new file mode 100644
index 000000000000..6ce909748b2c
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tsconfig.esm.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "module": "esnext",
+ "outDir": "dist/esm",
+ "verbatimModuleSyntax": true
+ },
+ "include": ["src"],
+ "exclude": []
+}
diff --git a/seed/ts-sdk/path-body-property-collision/tsconfig.json b/seed/ts-sdk/path-body-property-collision/tsconfig.json
new file mode 100644
index 000000000000..d77fdf00d259
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "./tsconfig.cjs.json"
+}
diff --git a/seed/ts-sdk/path-body-property-collision/vitest.config.mts b/seed/ts-sdk/path-body-property-collision/vitest.config.mts
new file mode 100644
index 000000000000..ba2ec4f9d45a
--- /dev/null
+++ b/seed/ts-sdk/path-body-property-collision/vitest.config.mts
@@ -0,0 +1,28 @@
+import { defineConfig } from "vitest/config";
+export default defineConfig({
+ test: {
+ projects: [
+ {
+ test: {
+ globals: true,
+ name: "unit",
+ environment: "node",
+ root: "./tests",
+ include: ["**/*.test.{js,ts,jsx,tsx}"],
+ exclude: ["wire/**"],
+ setupFiles: ["./setup.ts"],
+ },
+ },
+ {
+ test: {
+ globals: true,
+ name: "wire",
+ environment: "node",
+ root: "./tests/wire",
+ setupFiles: ["../setup.ts", "../mock-server/setup.ts"],
+ },
+ },
+ ],
+ passWithNoTests: true,
+ },
+});
diff --git a/test-definitions/fern/apis/path-body-property-collision/generators.yml b/test-definitions/fern/apis/path-body-property-collision/generators.yml
new file mode 100644
index 000000000000..d8bd49b048ba
--- /dev/null
+++ b/test-definitions/fern/apis/path-body-property-collision/generators.yml
@@ -0,0 +1,3 @@
+api:
+ specs:
+ - openapi: ./openapi.yml
diff --git a/test-definitions/fern/apis/path-body-property-collision/openapi.yml b/test-definitions/fern/apis/path-body-property-collision/openapi.yml
new file mode 100644
index 000000000000..e3a956e33df5
--- /dev/null
+++ b/test-definitions/fern/apis/path-body-property-collision/openapi.yml
@@ -0,0 +1,54 @@
+openapi: 3.0.3
+info:
+ title: Path Body Property Collision API
+ description: |
+ This API tests the case where a path parameter has the same name as a body property.
+ The TypeScript SDK generator should not generate duplicate properties in the request wrapper.
+ version: 1.0.0
+paths:
+ /resources/{key}/toggle:
+ post:
+ operationId: toggleResource
+ summary: Toggle a resource by key
+ description: |
+ This endpoint has a path parameter 'key' and a request body that also contains a 'key' property.
+ The generated request wrapper should only have one 'key' property (from the body).
+ parameters:
+ - name: key
+ in: path
+ required: true
+ description: The resource key from the path
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ToggleRequest'
+ responses:
+ '200':
+ description: Success
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ToggleResponse'
+components:
+ schemas:
+ ToggleRequest:
+ type: object
+ required:
+ - key
+ - enabled
+ properties:
+ key:
+ type: string
+ description: The resource key in the body
+ enabled:
+ type: boolean
+ description: Whether the resource should be enabled
+ ToggleResponse:
+ type: object
+ properties:
+ success:
+ type: boolean