Skip to content

Commit ea429d0

Browse files
committed
feat: handle binary responses based on OpenAPI spec
Detect binary response types directly from the OpenAPI responses field in templates (no extra Rust field needed). Template changes: - TypeScript: Check for 'application/octet-stream' in responses content, pass 'binary: true' to request(), return ArrayBuffer - Python: Check for 'application/octet-stream' in responses content, return response.content as bytes The binary check is derived at template render time from: operation.responses['200'].content['application/octet-stream'] Supported binary content types: - application/octet-stream This provides: - Better type safety (ArrayBuffer/bytes return types) - No runtime content-type parsing overhead - Correct IDE autocomplete and type inference - No redundant data in the operation model
1 parent 169286f commit ea429d0

File tree

5 files changed

+62
-11
lines changed

5 files changed

+62
-11
lines changed

xdk-gen/templates/python/client_macros.j2

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ stream_config: Optional[StreamConfig] = None
2828

2929
{# Macro for method return type #}
3030
{% macro return_type(operation) -%}
31-
{% if operation.is_streaming %}Generator[{% if operation.responses and "200" in operation.responses %}{{ operation.class_name }}Response{% else %}Dict[str, Any]{% endif %}, None, None]{% elif operation.parameters and (operation.parameters | selectattr('original_name', 'equalto', 'pagination_token') | list | length > 0 or operation.parameters | selectattr('original_name', 'equalto', 'next_token') | list | length > 0) %}Iterator[{% if operation.responses and "200" in operation.responses %}{{ operation.class_name }}Response{% else %}Dict[str, Any]{% endif %}]{% else %}{% if operation.responses and "200" in operation.responses %}{{ operation.class_name }}Response{% else %}Dict[str, Any]{% endif %}{% endif %}
31+
{% if operation.responses and (
32+
("200" in operation.responses and operation.responses["200"].content and "application/octet-stream" in operation.responses["200"].content) or
33+
("201" in operation.responses and operation.responses["201"].content and "application/octet-stream" in operation.responses["201"].content)
34+
) %}bytes{% elif operation.is_streaming %}Generator[{% if operation.responses and "200" in operation.responses %}{{ operation.class_name }}Response{% else %}Dict[str, Any]{% endif %}, None, None]{% elif operation.parameters and (operation.parameters | selectattr('original_name', 'equalto', 'pagination_token') | list | length > 0 or operation.parameters | selectattr('original_name', 'equalto', 'next_token') | list | length > 0) %}Iterator[{% if operation.responses and "200" in operation.responses %}{{ operation.class_name }}Response{% else %}Dict[str, Any]{% endif %}]{% else %}{% if operation.responses and "200" in operation.responses %}{{ operation.class_name }}Response{% else %}Dict[str, Any]{% endif %}{% endif %}
3235
{%- endmacro %}
3336

3437
{# Macro for method docstring #}
@@ -388,7 +391,15 @@ headers = {% if operation.is_streaming %}{
388391
response.raise_for_status()
389392

390393
# Parse the response data
394+
{% if operation.responses and (
395+
("200" in operation.responses and operation.responses["200"].content and "application/octet-stream" in operation.responses["200"].content) or
396+
("201" in operation.responses and operation.responses["201"].content and "application/octet-stream" in operation.responses["201"].content)
397+
) %}
398+
# Binary endpoint - return raw bytes
399+
return response.content
400+
{% else %}
391401
response_data = response.json()
402+
{% endif %}
392403

393404
# Convert to Pydantic model if applicable
394405
{% if operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}
@@ -594,7 +605,16 @@ headers = {% if operation.is_streaming %}{
594605
response.raise_for_status()
595606

596607
# Parse the response data
608+
{% if operation.responses and (
609+
("200" in operation.responses and operation.responses["200"].content and "application/octet-stream" in operation.responses["200"].content) or
610+
("201" in operation.responses and operation.responses["201"].content and "application/octet-stream" in operation.responses["201"].content)
611+
) %}
612+
# Binary endpoint - return raw bytes (pagination not applicable for binary)
613+
yield response.content
614+
return
615+
{% else %}
597616
response_data = response.json()
617+
{% endif %}
598618

599619
# Convert to Pydantic model if applicable
600620
{% if operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}

xdk-gen/templates/python/test_contracts.j2

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ class Test{{ tag.class_name }}Contracts:
6262
{% endfor %}
6363
}
6464
mock_response.raise_for_status.return_value = None
65+
mock_response.headers = {'content-type': 'application/json'}
6566
mock_session.{{ contract_test.method|lower }}.return_value = mock_response
6667

6768
# Prepare test parameters
@@ -236,6 +237,7 @@ class Test{{ tag.class_name }}Contracts:
236237
mock_response.status_code = 200
237238
mock_response.json.return_value = {}
238239
mock_response.raise_for_status.return_value = None
240+
mock_response.headers = {'content-type': 'application/json'}
239241
mock_session.{{ contract_test.method|lower }}.return_value = mock_response
240242

241243
try:
@@ -277,6 +279,7 @@ class Test{{ tag.class_name }}Contracts:
277279
mock_response.status_code = {{ contract_test.response_schema.status_code }}
278280
mock_response.json.return_value = mock_response_data
279281
mock_response.raise_for_status.return_value = None
282+
mock_response.headers = {'content-type': 'application/json'}
280283
mock_session.{{ contract_test.method|lower }}.return_value = mock_response
281284

282285
# Prepare minimal valid parameters

xdk-gen/templates/python/test_pagination.j2

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class Test{{ tag.class_name }}Pagination:
7171
}
7272
}
7373
first_page_response.raise_for_status.return_value = None
74+
first_page_response.headers = {'content-type': 'application/json'}
7475

7576
# Mock second page response (no next token = end of pagination)
7677
second_page_response = Mock()
@@ -84,6 +85,7 @@ class Test{{ tag.class_name }}Pagination:
8485
}
8586
}
8687
second_page_response.raise_for_status.return_value = None
88+
second_page_response.headers = {'content-type': 'application/json'}
8789

8890
# Return different responses for consecutive calls
8991
mock_session.get.side_effect = [first_page_response, second_page_response]
@@ -127,6 +129,7 @@ class Test{{ tag.class_name }}Pagination:
127129
}
128130
}
129131
mock_response.raise_for_status.return_value = None
132+
mock_response.headers = {'content-type': 'application/json'}
130133
mock_session.get.return_value = mock_response
131134

132135
# Test item iteration
@@ -152,6 +155,7 @@ class Test{{ tag.class_name }}Pagination:
152155
"meta": {"result_count": 0}
153156
}
154157
mock_response.raise_for_status.return_value = None
158+
mock_response.headers = {'content-type': 'application/json'}
155159
mock_session.get.return_value = mock_response
156160

157161
method = getattr(self.{{ tag.property_name }}_client, "{{ pagination_test.method_name }}")
@@ -191,6 +195,7 @@ class Test{{ tag.class_name }}Pagination:
191195
}
192196
}
193197
mock_response_with_token.raise_for_status.return_value = None
198+
mock_response_with_token.headers = {'content-type': 'application/json'}
194199

195200
second_page_response = Mock()
196201
second_page_response.status_code = 200
@@ -199,6 +204,7 @@ class Test{{ tag.class_name }}Pagination:
199204
"meta": {"result_count": 0}
200205
}
201206
second_page_response.raise_for_status.return_value = None
207+
second_page_response.headers = {'content-type': 'application/json'}
202208

203209
mock_session.get.side_effect = [mock_response_with_token, second_page_response]
204210

@@ -229,6 +235,7 @@ class Test{{ tag.class_name }}Pagination:
229235
empty_response.status_code = 200
230236
empty_response.json.return_value = {"data": [], "meta": {"result_count": 0}}
231237
empty_response.raise_for_status.return_value = None
238+
empty_response.headers = {'content-type': 'application/json'}
232239
mock_session.get.return_value = empty_response
233240

234241
# Pick first paginatable method for testing

xdk-gen/templates/typescript/client_class.j2

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,13 @@ export class {{ tag.class_name }}Client {
117117
{% if operation.request_body and operation.request_body.required %}
118118
* @param body {% if operation.request_body.content and operation.request_body.content["application/json"] and operation.request_body.content["application/json"].schema and operation.request_body.content["application/json"].schema.description %}{{ operation.request_body.content["application/json"].schema.description }}{% else %}Request body{% endif %}
119119
{% endif %}
120-
* @returns {Promise<{% if operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %}>} Promise resolving to the API response, or raw Response if requestOptions.raw is true
120+
* @returns {Promise<{% if operation.responses and (
121+
("200" in operation.responses and operation.responses["200"].content and "application/octet-stream" in operation.responses["200"].content) or
122+
("201" in operation.responses and operation.responses["201"].content and "application/octet-stream" in operation.responses["201"].content)
123+
) %}ArrayBuffer{% elif operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %}>} Promise resolving to the API response{% if operation.responses and (
124+
("200" in operation.responses and operation.responses["200"].content and "application/octet-stream" in operation.responses["200"].content) or
125+
("201" in operation.responses and operation.responses["201"].content and "application/octet-stream" in operation.responses["201"].content)
126+
) %} as binary ArrayBuffer{% endif %}, or raw Response if requestOptions.raw is true
121127
*/
122128
// Overload 1: raw: true returns Response
123129
{{ operation.method_name }}(
@@ -158,7 +164,10 @@ export class {{ tag.class_name }}Client {
158164
{% if operation.parameters | rejectattr('required') | rejectattr('location', 'equalto', 'path') | list | length > 0 or (operation.request_body and not operation.request_body.required) %}
159165
options?: {{ operation.class_name }}Options
160166
{% endif %}
161-
): Promise<{% if operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %}>;
167+
): Promise<{% if operation.responses and (
168+
("200" in operation.responses and operation.responses["200"].content and "application/octet-stream" in operation.responses["200"].content) or
169+
("201" in operation.responses and operation.responses["201"].content and "application/octet-stream" in operation.responses["201"].content)
170+
) %}ArrayBuffer{% elif operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %}>;
162171
// Implementation
163172
async {{ operation.method_name }}(
164173
{# Path parameters are always required - use location field #}
@@ -181,7 +190,10 @@ export class {{ tag.class_name }}Client {
181190
{% if operation.parameters | rejectattr('required') | rejectattr('location', 'equalto', 'path') | list | length > 0 or (operation.request_body and not operation.request_body.required) %}
182191
options: {{ operation.class_name }}Options = {}
183192
{% endif %}
184-
): Promise<{% if operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %} | Response> {
193+
): Promise<{% if operation.responses and (
194+
("200" in operation.responses and operation.responses["200"].content and "application/octet-stream" in operation.responses["200"].content) or
195+
("201" in operation.responses and operation.responses["201"].content and "application/octet-stream" in operation.responses["201"].content)
196+
) %}ArrayBuffer{% elif operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %} | Response> {
185197
// Normalize options to handle both camelCase and original API parameter names
186198
{% if operation.parameters | rejectattr('required') | rejectattr('location', 'equalto', 'path') | list | length > 0 or (operation.request_body and not operation.request_body.required) %}
187199
{% if operation.parameters | rejectattr('required') | rejectattr('location', 'equalto', 'path') | list | length > 0 %}
@@ -280,10 +292,16 @@ export class {{ tag.class_name }}Client {
280292
{% endif %}
281293
};
282294

283-
return this.client.request<{% if operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %}>(
295+
return this.client.request<{% if operation.responses and (
296+
("200" in operation.responses and operation.responses["200"].content and "application/octet-stream" in operation.responses["200"].content) or
297+
("201" in operation.responses and operation.responses["201"].content and "application/octet-stream" in operation.responses["201"].content)
298+
) %}ArrayBuffer{% elif operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %}>(
284299
'{{ operation.method | upper }}',
285300
path + (params.toString() ? `?${params.toString()}` : ''),
286-
finalRequestOptions
301+
{% if operation.responses and (
302+
("200" in operation.responses and operation.responses["200"].content and "application/octet-stream" in operation.responses["200"].content) or
303+
("201" in operation.responses and operation.responses["201"].content and "application/octet-stream" in operation.responses["201"].content)
304+
) %}{ ...finalRequestOptions, binary: true }{% else %}finalRequestOptions{% endif %}
287305
);
288306
}
289307

xdk-gen/templates/typescript/main_client.j2

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ export interface RequestOptions {
7777
raw?: boolean;
7878
/** Security requirements for the endpoint (from OpenAPI spec) - used for smart auth selection */
7979
security?: Array<Record<string, string[]>>;
80+
/** Whether this endpoint returns binary data (ArrayBuffer) - determined from OpenAPI spec */
81+
binary?: boolean;
8082
}
8183

8284
/**
@@ -416,13 +418,14 @@ export class Client {
416418
}
417419

418420
let data: T;
419-
const contentType = response.headers.get('content-type');
420-
if (contentType && contentType.includes('application/json')) {
421+
// Check if binary response is expected (from OpenAPI spec)
422+
if (options.binary) {
423+
// Return ArrayBuffer for binary endpoints
424+
data = await response.arrayBuffer() as T;
425+
} else {
426+
// Default: parse as JSON and transform keys
421427
const rawData = await response.json();
422-
// Transform snake_case keys to camelCase to match TypeScript conventions
423428
data = transformKeys<T>(rawData);
424-
} else {
425-
data = await response.text() as T;
426429
}
427430

428431
// Return parsed body for non-streaming requests

0 commit comments

Comments
 (0)