Skip to content

Commit ffdb021

Browse files
tolik0devin-ai-integration[bot]claude
committed
feat: add JsonItemsDecoder and always_decompress for streaming large gzipped JSON
Adds a streaming JSON decoder for very large single-document JSON responses, plus an option to force gzip decompression when responses are mislabeled. - JsonItemsParser/JsonItemsDecoder: stream-yield each element of a nested array via ijson, so peak memory is bounded by one record instead of the full document. Composes with the existing CompositeRawDecoder hierarchy (gzip/zip). Adds ijson as a first-class CDK dependency. - GzipDecoder.always_decompress: opt-in flag to always gzip-decompress the body regardless of Content-Encoding/Content-Type. Needed for APIs that return gzip bodies labeled 'Content-Encoding: identity' (e.g. Amazon Seller Partner reports), where header-based selection would otherwise skip decompression. Supersedes #1026 (adopts its JsonItemsDecoder work). Co-Authored-By: devin-ai-integration[bot] <devin-ai-integration[bot]@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 09cc18f commit ffdb021

7 files changed

Lines changed: 691 additions & 282 deletions

File tree

airbyte_cdk/sources/declarative/declarative_component_schema.yaml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2643,6 +2643,40 @@ definitions:
26432643
type:
26442644
type: string
26452645
enum: [JsonDecoder]
2646+
JsonItemsDecoder:
2647+
title: JSON Items (Streaming)
2648+
description: >-
2649+
Select 'JSON Items (Streaming)' to stream-decode a single JSON document
2650+
by yielding each element of a nested array, one at a time. Use this for
2651+
very large single-document JSON responses (e.g. a wrapping object
2652+
containing a multi-GB array) where buffering the whole document into
2653+
memory would cause out-of-memory errors. Powered by the `ijson`
2654+
streaming parser.
2655+
type: object
2656+
required:
2657+
- type
2658+
- items_path
2659+
properties:
2660+
type:
2661+
type: string
2662+
enum: [JsonItemsDecoder]
2663+
items_path:
2664+
title: Items Path
2665+
description: >-
2666+
Dot-separated path to the JSON array whose elements should be
2667+
yielded as records. Uses `ijson` path syntax (e.g. `data.users`),
2668+
not JSONPath syntax — do not include leading `$.` or trailing
2669+
`[*]`.
2670+
type: string
2671+
examples:
2672+
- dataByDepartmentAndSearchTerm
2673+
- dataByAsin
2674+
- data.users
2675+
encoding:
2676+
title: Encoding
2677+
description: Text encoding used to decode the streamed bytes before JSON parsing.
2678+
type: string
2679+
default: utf-8
26462680
JsonlDecoder:
26472681
title: JSON Lines
26482682
description: Select 'JSON Lines' if the response consists of JSON objects separated by new lines ('\n') in JSONL format.
@@ -2884,6 +2918,7 @@ definitions:
28842918
- "$ref": "#/definitions/CsvDecoder"
28852919
- "$ref": "#/definitions/GzipDecoder"
28862920
- "$ref": "#/definitions/JsonDecoder"
2921+
- "$ref": "#/definitions/JsonItemsDecoder"
28872922
- "$ref": "#/definitions/JsonlDecoder"
28882923
ListPartitionRouter:
28892924
title: List Partition Router
@@ -3924,6 +3959,7 @@ definitions:
39243959
description: Component decoding the response so records can be extracted.
39253960
anyOf:
39263961
- "$ref": "#/definitions/JsonDecoder"
3962+
- "$ref": "#/definitions/JsonItemsDecoder"
39273963
- "$ref": "#/definitions/XmlDecoder"
39283964
- "$ref": "#/definitions/CsvDecoder"
39293965
- "$ref": "#/definitions/JsonlDecoder"
@@ -4007,11 +4043,23 @@ definitions:
40074043
type:
40084044
type: string
40094045
enum: [GzipDecoder]
4046+
always_decompress:
4047+
title: Always Decompress
4048+
description: >-
4049+
By default the response is only gzip-decompressed when its
4050+
'Content-Encoding'/'Content-Type' headers advertise gzip. Set this to
4051+
'true' to always gzip-decompress the response body regardless of its
4052+
headers. Use this for APIs that return gzip-compressed bodies while
4053+
mislabeling them as uncompressed (e.g. 'Content-Encoding: identity'),
4054+
such as Amazon Seller Partner report documents.
4055+
type: boolean
4056+
default: false
40104057
decoder:
40114058
anyOf:
40124059
- "$ref": "#/definitions/CsvDecoder"
40134060
- "$ref": "#/definitions/GzipDecoder"
40144061
- "$ref": "#/definitions/JsonDecoder"
4062+
- "$ref": "#/definitions/JsonItemsDecoder"
40154063
- "$ref": "#/definitions/JsonlDecoder"
40164064
CsvDecoder:
40174065
title: CSV
@@ -4178,6 +4226,7 @@ definitions:
41784226
- "$ref": "#/definitions/CsvDecoder"
41794227
- "$ref": "#/definitions/GzipDecoder"
41804228
- "$ref": "#/definitions/JsonDecoder"
4229+
- "$ref": "#/definitions/JsonItemsDecoder"
41814230
- "$ref": "#/definitions/JsonlDecoder"
41824231
- "$ref": "#/definitions/IterableDecoder"
41834232
- "$ref": "#/definitions/XmlDecoder"
@@ -4190,6 +4239,7 @@ definitions:
41904239
- "$ref": "#/definitions/CsvDecoder"
41914240
- "$ref": "#/definitions/GzipDecoder"
41924241
- "$ref": "#/definitions/JsonDecoder"
4242+
- "$ref": "#/definitions/JsonItemsDecoder"
41934243
- "$ref": "#/definitions/JsonlDecoder"
41944244
- "$ref": "#/definitions/IterableDecoder"
41954245
- "$ref": "#/definitions/XmlDecoder"

airbyte_cdk/sources/declarative/decoders/composite_raw_decoder.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from io import BufferedIOBase, TextIOWrapper
1212
from typing import Any, List, Optional
1313

14+
import ijson
1415
import orjson
1516
import requests
1617

@@ -98,6 +99,33 @@ def parse(self, data: BufferedIOBase) -> PARSER_OUTPUT_TYPE:
9899
logger.warning(f"Cannot decode/parse line {line!r} as JSON, error: {e}")
99100

100101

102+
@dataclass
103+
class JsonItemsParser(Parser):
104+
"""Streaming JSON parser that yields each element of a nested array.
105+
106+
Use this for very large single-document JSON responses where the records
107+
of interest live under a nested array (e.g. `dataByDepartmentAndSearchTerm`,
108+
`data.users`). Powered by `ijson`, this parser does not materialize the
109+
full document — peak memory is bounded by a single record plus ijson's
110+
internal parse buffers, regardless of document size.
111+
112+
`items_path` uses `ijson` dotted path syntax (e.g. `data.users`), not
113+
JSONPath syntax (`$.data.users[*]`). Internally we append `.item`, which
114+
is the `ijson` convention for "iterate elements of this array".
115+
"""
116+
117+
items_path: str = ""
118+
encoding: Optional[str] = "utf-8"
119+
120+
def parse(self, data: BufferedIOBase) -> PARSER_OUTPUT_TYPE:
121+
if not self.items_path:
122+
raise ValueError("JsonItemsParser requires a non-empty items_path.")
123+
# ijson auto-selects the best available backend (yajl2_c when present)
124+
# and reads from `data` lazily — it does not call `.read()` on the
125+
# whole stream up front.
126+
yield from ijson.items(data, f"{self.items_path}.item")
127+
128+
101129
@dataclass
102130
class CsvParser(Parser):
103131
# TODO: migrate implementation to re-use file-base classes

airbyte_cdk/sources/declarative/models/declarative_component_schema.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,20 @@ class JsonDecoder(BaseModel):
674674
type: Literal["JsonDecoder"]
675675

676676

677+
class JsonItemsDecoder(BaseModel):
678+
type: Literal["JsonItemsDecoder"]
679+
items_path: str = Field(
680+
...,
681+
description="Dot-separated path to the JSON array whose elements should be yielded as records. Uses `ijson` path syntax (e.g. `data.users`), not JSONPath syntax \u2014 do not include leading `$.` or trailing `[*]`.",
682+
title="Items Path",
683+
)
684+
encoding: Optional[str] = Field(
685+
"utf-8",
686+
description="The character encoding of the JSON data. Defaults to UTF-8.",
687+
title="Encoding",
688+
)
689+
690+
677691
class JsonlDecoder(BaseModel):
678692
type: Literal["JsonlDecoder"]
679693

@@ -2195,7 +2209,19 @@ class PaginationReset(BaseModel):
21952209

21962210
class GzipDecoder(BaseModel):
21972211
type: Literal["GzipDecoder"]
2198-
decoder: Union[CsvDecoder, GzipDecoder, JsonDecoder, JsonlDecoder]
2212+
always_decompress: Optional[bool] = Field(
2213+
False,
2214+
description=(
2215+
"By default the response is only gzip-decompressed when its "
2216+
"'Content-Encoding'/'Content-Type' headers advertise gzip. Set this to "
2217+
"'true' to always gzip-decompress the response body regardless of its "
2218+
"headers. Use this for APIs that return gzip-compressed bodies while "
2219+
"mislabeling them as uncompressed (e.g. 'Content-Encoding: identity'), "
2220+
"such as Amazon Seller Partner report documents."
2221+
),
2222+
title="Always Decompress",
2223+
)
2224+
decoder: Union[CsvDecoder, GzipDecoder, JsonDecoder, JsonItemsDecoder, JsonlDecoder]
21992225

22002226

22012227
class RequestBodyGraphQL(BaseModel):
@@ -2333,7 +2359,7 @@ class Config:
23332359
extra = Extra.allow
23342360

23352361
type: Literal["ZipfileDecoder"]
2336-
decoder: Union[CsvDecoder, GzipDecoder, JsonDecoder, JsonlDecoder] = Field(
2362+
decoder: Union[CsvDecoder, GzipDecoder, JsonDecoder, JsonItemsDecoder, JsonlDecoder] = Field(
23372363
...,
23382364
description="Parser to parse the decompressed data from the zipfile(s).",
23392365
title="Parser",
@@ -3005,6 +3031,7 @@ class SimpleRetriever(BaseModel):
30053031
decoder: Optional[
30063032
Union[
30073033
JsonDecoder,
3034+
JsonItemsDecoder,
30083035
XmlDecoder,
30093036
CsvDecoder,
30103037
JsonlDecoder,
@@ -3138,6 +3165,7 @@ class AsyncRetriever(BaseModel):
31383165
CsvDecoder,
31393166
GzipDecoder,
31403167
JsonDecoder,
3168+
JsonItemsDecoder,
31413169
JsonlDecoder,
31423170
IterableDecoder,
31433171
XmlDecoder,
@@ -3154,6 +3182,7 @@ class AsyncRetriever(BaseModel):
31543182
CsvDecoder,
31553183
GzipDecoder,
31563184
JsonDecoder,
3185+
JsonItemsDecoder,
31573186
JsonlDecoder,
31583187
IterableDecoder,
31593188
XmlDecoder,

airbyte_cdk/sources/declarative/parsers/model_to_component_factory.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
CompositeRawDecoder,
9898
CsvParser,
9999
GzipParser,
100+
JsonItemsParser,
100101
JsonLineParser,
101102
JsonParser,
102103
Parser,
@@ -321,6 +322,9 @@
321322
from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
322323
JsonFileSchemaLoader as JsonFileSchemaLoaderModel,
323324
)
325+
from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
326+
JsonItemsDecoder as JsonItemsDecoderModel,
327+
)
324328
from airbyte_cdk.sources.declarative.models.declarative_component_schema import (
325329
JsonlDecoder as JsonlDecoderModel,
326330
)
@@ -763,6 +767,7 @@ def _init_mappings(self) -> None:
763767
HttpResponseFilterModel: self.create_http_response_filter,
764768
InlineSchemaLoaderModel: self.create_inline_schema_loader,
765769
JsonDecoderModel: self.create_json_decoder,
770+
JsonItemsDecoderModel: self.create_json_items_decoder,
766771
JsonlDecoderModel: self.create_jsonl_decoder,
767772
JsonSchemaPropertySelectorModel: self.create_json_schema_property_selector,
768773
GzipDecoderModel: self.create_gzip_decoder,
@@ -2671,6 +2676,14 @@ def create_jsonl_decoder(
26712676
stream_response=False if self._emit_connector_builder_messages else True,
26722677
)
26732678

2679+
def create_json_items_decoder(
2680+
self, model: JsonItemsDecoderModel, config: Config, **kwargs: Any
2681+
) -> Decoder:
2682+
return CompositeRawDecoder(
2683+
parser=ModelToComponentFactory._get_parser(model, config),
2684+
stream_response=False if self._emit_connector_builder_messages else True,
2685+
)
2686+
26742687
def create_gzip_decoder(
26752688
self, model: GzipDecoderModel, config: Config, **kwargs: Any
26762689
) -> Decoder:
@@ -2693,6 +2706,12 @@ def create_gzip_decoder(
26932706
# which uses urllib3 directly and does not uncompress the data.
26942707
return CompositeRawDecoder(gzip_parser.inner_parser, False)
26952708

2709+
if getattr(model, "always_decompress", False):
2710+
# Some APIs return gzip-compressed bodies while mislabeling them as uncompressed
2711+
# (e.g. Amazon Seller Partner reports send "Content-Encoding: identity"). Header-based
2712+
# selection would then skip decompression, so always run the GzipParser here.
2713+
return CompositeRawDecoder(gzip_parser, stream_response=True)
2714+
26962715
return CompositeRawDecoder.by_headers(
26972716
[({"Content-Encoding", "Content-Type"}, _compressed_response_types, gzip_parser)],
26982717
stream_response=True,
@@ -2719,6 +2738,11 @@ def _get_parser(model: BaseModel, config: Config) -> Parser:
27192738
if isinstance(model, JsonDecoderModel):
27202739
# Note that the logic is a bit different from the JsonDecoder as there is some legacy that is maintained to return {} on error cases
27212740
return JsonParser()
2741+
elif isinstance(model, JsonItemsDecoderModel):
2742+
return JsonItemsParser(
2743+
items_path=model.items_path,
2744+
encoding=model.encoding or "utf-8",
2745+
)
27222746
elif isinstance(model, JsonlDecoderModel):
27232747
return JsonLineParser()
27242748
elif isinstance(model, CsvDecoderModel):

0 commit comments

Comments
 (0)