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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .github/workflows/publish-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ jobs:
uses: actions/checkout@v4
with:
ref: main
fetch-depth: 2
fetch-depth: 20

- name: Install
uses: ./.github/actions/install
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Fern's model generators will output schemas or types defined in your OpenAPI spe
| Generator ID | Latest Version | Entrypoint |
| ----------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| `fernapi/fern-pydantic-model` | ![Pydantic Model Generator Version](https://img.shields.io/docker/v/fernapi/fern-pydantic-model) | [cli.py](./generators/python/src/fern_python/generators/sdk/cli.py) |
| `fernapi/java-model` | ![Java Model Generator Version](https://img.shields.io/docker/v/fernapi/java-model) | [Cli.java](./generators/java/sdk/src/main/java/com/fern/java/client/Cli.java) |
| `fernapi/fern-java-model` | ![Java Model Generator Version](https://img.shields.io/docker/v/fernapi/fern-java-model) | [Cli.java](./generators/java/sdk/src/main/java/com/fern/java/client/Cli.java) |
| `fernapi/fern-ruby-model` | ![Ruby Model Generator Version](https://img.shields.io/docker/v/fernapi/fern-ruby-model) | [cli.ts](./generators/ruby/model/src/cli.ts) |
| `fernapi/fern-go-model` | ![Go Model Generator Version](https://img.shields.io/docker/v/fernapi/fern-go-model) | [main.go](./generators/go/cmd/fern-go-model/main.go) |

Expand Down
46 changes: 46 additions & 0 deletions docs-yml.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,16 @@
}
]
},
"ai-examples": {
"oneOf": [
{
"$ref": "#/definitions/docs.AiExamplesConfig"
},
{
"type": "null"
}
]
},
"metadata": {
"oneOf": [
{
Expand Down Expand Up @@ -1552,6 +1562,16 @@
"docs.PlaygroundSettings": {
"type": "object",
"properties": {
"hidden": {
"oneOf": [
{
"type": "boolean"
},
{
"type": "null"
}
]
},
"environments": {
"oneOf": [
{
Expand Down Expand Up @@ -3911,6 +3931,32 @@
},
"additionalProperties": false
},
"docs.AiExamplesConfig": {
"type": "object",
"properties": {
"enabled": {
"oneOf": [
{
"type": "boolean"
},
{
"type": "null"
}
]
},
"style": {
"oneOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
"docs.TwitterCardSetting": {
"type": "string",
"enum": [
Expand Down
26 changes: 24 additions & 2 deletions fern/apis/docs-yml/definition/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ types:
# properties:
# app-id: string

AiExamplesConfig:
properties:
enabled:
type: optional<boolean>
docs: |
Enable AI-powered example enhancement for API documentation. When enabled, API examples will be enhanced with AI-generated content to provide more comprehensive and realistic examples.
style:
type: optional<string>
docs: |
Custom styling instructions for AI-generated examples. When provided, these instructions will guide the AI in generating examples that match your preferred style, naming conventions, or domain-specific terminology. Limited to 500 characters.

DocsConfiguration:
properties:
instances: list<DocsInstance>
Expand Down Expand Up @@ -179,6 +190,12 @@ types:

"ai-search": optional<AIChatConfig>

# ai examples
"ai-examples":
type: optional<AiExamplesConfig>
docs: |
Configure AI-powered example enhancement for API documentation. When enabled, API examples will be enhanced with AI-generated content to provide more comprehensive and realistic examples.

# seo
metadata: optional<MetadataConfig>
redirects: optional<list<RedirectConfig>>
Expand Down Expand Up @@ -1633,17 +1650,22 @@ types:
Enable dynamic snippets in `docs.yml`, then configure them by following the SDK snippets setup instructions.
ai-examples:
type: optional<boolean>
availability: pre-release
availability: deprecated
docs: |
Enable AI-powered example enhancement for API documentation. When enabled, API examples will be enhanced with AI-generated content to provide more comprehensive and realistic examples.

DEPRECATED: Use the top-level `ai-examples` property instead.
ai-example-style-instructions:
type: optional<string>
availability: pre-release
availability: deprecated
docs: |
Custom styling instructions for AI-generated examples. When provided, these instructions will guide the AI in generating examples that match your preferred style, naming conventions, or domain-specific terminology. Limited to 500 characters.

DEPRECATED: Use the top-level `ai-example-style-instructions` property instead.

PlaygroundSettings:
properties:
hidden: optional<boolean>
environments:
type: optional<list<string>>
docs: A list of environment IDs that are allowed to be used in the playground.
Expand Down
81 changes: 65 additions & 16 deletions generators/python-v2/sdk/src/wire-tests/WireTestSetupGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class WireTestSetupGenerator {
wiremock:
image: wiremock/wiremock:3.9.1
ports:
- "8080:8080"
- "0:8080" # Use dynamic port to avoid conflicts with concurrent tests
volumes:
- ./wiremock-mappings.json:/home/wiremock/mappings/wiremock-mappings.json
command: ["--global-response-templating", "--verbose"]
Expand Down Expand Up @@ -121,14 +121,23 @@ The WireMock container lifecycle itself is managed by a top-level pytest
plugin (wiremock_pytest_plugin.py) so that the container is started exactly
once per test run, even when using pytest-xdist.
"""

import inspect
import os
from typing import Any, Dict, Optional

import requests
import inspect

${clientImport}
${environmentSetup.imports}


def _get_wiremock_base_url() -> str:
"""Returns the WireMock base URL using the dynamically assigned port."""
port = os.environ.get("WIREMOCK_PORT", "8080")
return f"http://localhost:{port}"


def get_client(test_id: str) -> ${clientClassName}:
"""
Creates a configured client instance for wire tests.
Expand All @@ -140,12 +149,13 @@ def get_client(test_id: str) -> ${clientClassName}:
A configured client instance with all required auth parameters.
"""
test_headers = {"X-Test-Id": test_id}
base_url = _get_wiremock_base_url()

# Prefer passing headers directly if the client constructor supports it.
try:
if "headers" in inspect.signature(${clientClassName}).parameters:
return ${clientClassName}(
${environmentSetup.param},
${environmentSetup.paramDynamic},
headers=test_headers,
${clientConstructorParams}
)
Expand All @@ -155,7 +165,7 @@ ${clientConstructorParams}
import httpx

return ${clientClassName}(
${environmentSetup.param},
${environmentSetup.paramDynamic},
httpx_client=httpx.Client(headers=test_headers),
${clientConstructorParams}
)
Expand All @@ -169,7 +179,7 @@ def verify_request_count(
expected: int,
) -> None:
"""Verifies the number of requests made to WireMock filtered by test ID for concurrency safety"""
wiremock_admin_url = "http://localhost:8080/__admin"
wiremock_admin_url = f"{_get_wiremock_base_url()}/__admin"
request_body: Dict[str, Any] = {
"method": method,
"urlPath": url_path,
Expand Down Expand Up @@ -224,12 +234,11 @@ by pytest's normal test collection rules.

import os
import subprocess
from typing import Optional

import pytest


_STARTED: bool = False
_WIREMOCK_PORT: str = "8080" # Default, will be updated after container starts


def _compose_file() -> str:
Expand All @@ -241,22 +250,54 @@ def _compose_file() -> str:
return os.path.join(wiremock_dir, "docker-compose.test.yml")


def _project_name() -> str:
"""Returns a unique project name for this test fixture to avoid container name conflicts."""
tests_dir = os.path.dirname(__file__)
project_root = os.path.abspath(os.path.join(tests_dir, ".."))
# Use the last two directory names to create a unique project name
# e.g., "python-streaming-parameter-openapi-with-wire-tests"
parent = os.path.basename(os.path.dirname(project_root))
current = os.path.basename(project_root)
return f"{parent}-{current}".replace("_", "-").lower()


def _get_wiremock_port() -> str:
"""Gets the dynamically assigned port for the WireMock container."""
compose_file = _compose_file()
project = _project_name()
try:
result = subprocess.run(
["docker", "compose", "-f", compose_file, "-p", project, "port", "wiremock", "8080"],
check=True,
capture_output=True,
text=True,
)
# Output is like "0.0.0.0:32768" or "[::]:32768"
port = result.stdout.strip().split(":")[-1]
return port
except subprocess.CalledProcessError:
return "8080" # Fallback to default


def _start_wiremock() -> None:
"""Starts the WireMock container using docker-compose."""
global _STARTED
global _STARTED, _WIREMOCK_PORT
if _STARTED:
return

compose_file = _compose_file()
print("\\nStarting WireMock container...")
project = _project_name()
print(f"\\nStarting WireMock container (project: {project})...")
try:
subprocess.run(
["docker", "compose", "-f", compose_file, "up", "-d", "--wait"],
["docker", "compose", "-f", compose_file, "-p", project, "up", "-d", "--wait"],
check=True,
capture_output=True,
text=True,
)
print("WireMock container is ready")
_WIREMOCK_PORT = _get_wiremock_port()
os.environ["WIREMOCK_PORT"] = _WIREMOCK_PORT
print(f"WireMock container is ready on port {_WIREMOCK_PORT}")
_STARTED = True
except subprocess.CalledProcessError as e:
print(f"Failed to start WireMock: {e.stderr}")
Expand All @@ -266,9 +307,10 @@ def _start_wiremock() -> None:
def _stop_wiremock() -> None:
"""Stops and removes the WireMock container."""
compose_file = _compose_file()
project = _project_name()
print("\\nStopping WireMock container...")
subprocess.run(
["docker", "compose", "-f", compose_file, "down", "-v"],
["docker", "compose", "-f", compose_file, "-p", project, "down", "-v"],
check=False,
capture_output=True,
)
Expand Down Expand Up @@ -381,14 +423,15 @@ def pytest_unconfigure(config: pytest.Config) -> None:
* create a custom environment instance that points all URLs to WireMock.
* If no environments are defined, we use base_url directly.
*/
private buildEnvironmentSetup(): { imports: string; param: string } {
private buildEnvironmentSetup(): { imports: string; param: string; paramDynamic: string } {
const environments = this.ir.environments;

if (environments?.environments.type !== "multipleBaseUrls") {
// No environments defined - use base_url directly
return {
imports: "",
param: 'base_url="http://localhost:8080"'
param: 'base_url="http://localhost:8080"',
paramDynamic: "base_url=base_url"
};
}

Expand All @@ -398,14 +441,20 @@ def pytest_unconfigure(config: pytest.Config) -> None:
const environmentClassName = this.getEnvironmentClassName();
const modulePath = this.getModulePath();

// Build kwargs for all base URLs pointing to WireMock
// Build kwargs for all base URLs pointing to WireMock (static version for backwards compat)
const baseUrlKwargs = envConfig.baseUrls
.map((baseUrl) => `${baseUrl.name.snakeCase.safeName}="http://localhost:8080"`)
.join(", ");

// Build kwargs for all base URLs using dynamic base_url variable
const baseUrlKwargsDynamic = envConfig.baseUrls
.map((baseUrl) => `${baseUrl.name.snakeCase.safeName}=base_url`)
.join(", ");

return {
imports: `from ${modulePath}.environment import ${environmentClassName}`,
param: `environment=${environmentClassName}(${baseUrlKwargs})`
param: `environment=${environmentClassName}(${baseUrlKwargs})`,
paramDynamic: `environment=${environmentClassName}(${baseUrlKwargsDynamic})`
};
}

Expand Down
13 changes: 13 additions & 0 deletions generators/python/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
# For unreleased changes, use unreleased.yml

- version: 4.48.1
changelogEntry:
- summary: |
Fix WireMock stub generation for streaming endpoints. When an endpoint has both streaming
and non-streaming variants (via x-fern-streaming with stream-condition), the generated
WireMock stubs now include request body matching criteria to differentiate between them.
SSE stubs match on `stream: true` in the request body and have higher priority, ensuring
wire tests correctly route streaming requests to the SSE stub.
type: fix
createdAt: "2026-01-14"
irVersion: 62

- version: 4.48.0
changelogEntry:
- summary: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@ def initialize(base_url:, path:, method:, headers: {}, query: {}, request_option
@request_options = request_options
end

# @return [Hash] The query parameters merged with additional query parameters from request options.
def encode_query
additional_query = @request_options&.dig(:additional_query_parameters) || @request_options&.dig("additional_query_parameters") || {}
@query.merge(additional_query)
end

# Child classes should implement:
# - encode_headers: Returns the encoded HTTP request headers.
# - encode_body: Returns the encoded HTTP request body.
end
end
end
end
end
Loading
Loading