Skip to content

Commit 8fb66f2

Browse files
feat: normalize state and catalog to Airbyte protocol format by default (#1017)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: patrick.nilan@airbyte.io <patrick.nilan@airbyte.io>
1 parent 82bf1e4 commit 8fb66f2

8 files changed

Lines changed: 935 additions & 39 deletions

File tree

airbyte/cloud/_case_conversion.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
"""Generic camelCase ↔ snake_case key conversion helpers.
3+
4+
These helpers convert dict keys between camelCase (Config API format)
5+
and snake_case (Airbyte protocol format). Values are left unchanged;
6+
conversion is shallow (one level of dict keys) so opaque payloads
7+
like `streamState` blobs and `jsonSchema` dicts are never modified.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import re
13+
from typing import Any
14+
15+
16+
_CAMEL_BOUNDARY = re.compile(r"([A-Z])")
17+
18+
19+
def _camel_to_snake(key: str) -> str:
20+
"""Convert a single camelCase key to snake_case.
21+
22+
Examples: `streamDescriptor` → `stream_descriptor`,
23+
`jsonSchema` → `json_schema`, `name` → `name`.
24+
"""
25+
return _CAMEL_BOUNDARY.sub(r"_\1", key).lower().lstrip("_")
26+
27+
28+
def _snake_to_camel(key: str) -> str:
29+
"""Convert a single snake_case key to camelCase.
30+
31+
Examples: `stream_descriptor` → `streamDescriptor`,
32+
`json_schema` → `jsonSchema`, `name` → `name`.
33+
"""
34+
parts = key.split("_")
35+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
36+
37+
38+
def camel_to_snake_keys(data: dict[str, Any]) -> dict[str, Any]:
39+
"""Shallow-convert all dict keys from camelCase to snake_case.
40+
41+
Only the immediate keys of `data` are converted; nested dicts and
42+
lists inside values are returned as-is. This prevents accidental
43+
mutation of opaque payloads (connector state blobs, JSON Schema, etc.).
44+
"""
45+
return {_camel_to_snake(k): v for k, v in data.items()}
46+
47+
48+
def snake_to_camel_keys(data: dict[str, Any]) -> dict[str, Any]:
49+
"""Shallow-convert all dict keys from snake_case to camelCase.
50+
51+
Only the immediate keys of `data` are converted; nested dicts and
52+
lists inside values are returned as-is. This prevents accidental
53+
mutation of opaque payloads (connector state blobs, JSON Schema, etc.).
54+
"""
55+
return {_snake_to_camel(k): v for k, v in data.items()}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
"""Normalization helpers for connection catalog format conversion.
3+
4+
The Config API returns catalogs using camelCase keys (`syncCatalog` format).
5+
The Airbyte protocol uses snake_case keys (`ConfiguredAirbyteCatalog` format).
6+
These helpers convert between the two representations using generic
7+
camelCase ↔ snake_case key converters rather than hard-coded field maps.
8+
9+
Structural differences (flattening/nesting the `config` block) are handled
10+
by thin wrappers around the generic converters.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import Any
16+
17+
from airbyte.cloud._case_conversion import camel_to_snake_keys, snake_to_camel_keys
18+
19+
20+
def _normalize_catalog_to_protocol(
21+
sync_catalog: dict[str, Any],
22+
) -> dict[str, Any]:
23+
"""Convert a `syncCatalog` dict to `ConfiguredAirbyteCatalog` protocol format.
24+
25+
Transforms camelCase keys to snake_case and flattens the `config` block into
26+
each stream entry, matching the structure expected by connector `--catalog` flags.
27+
28+
Args:
29+
sync_catalog: Catalog dict from the Config API (camelCase keys, nested `config`).
30+
31+
Returns:
32+
Catalog dict in Airbyte protocol format (snake_case keys, flat stream entries).
33+
"""
34+
configured_streams: list[dict[str, Any]] = []
35+
36+
for stream_config in sync_catalog.get("streams", []):
37+
stream_info = stream_config.get("stream", {})
38+
config_info = stream_config.get("config", {})
39+
40+
# Convert stream-level keys from camelCase → snake_case (shallow).
41+
# Opaque values like jsonSchema content are preserved as-is.
42+
normalized_stream = camel_to_snake_keys(stream_info)
43+
44+
# Build the configured stream entry with flattened config keys.
45+
configured_entry: dict[str, Any] = {"stream": normalized_stream}
46+
configured_entry.update(camel_to_snake_keys(config_info))
47+
48+
configured_streams.append(configured_entry)
49+
50+
return {"streams": configured_streams}
51+
52+
53+
def _denormalize_catalog_to_api(
54+
configured_catalog: dict[str, Any],
55+
) -> dict[str, Any]:
56+
"""Convert a `ConfiguredAirbyteCatalog` dict back to `syncCatalog` API format.
57+
58+
Reverses `_normalize_catalog_to_protocol`: converts snake_case keys to camelCase
59+
and nests config fields back under a `config` block.
60+
61+
Args:
62+
configured_catalog: Catalog dict in Airbyte protocol format.
63+
64+
Returns:
65+
Catalog dict in Config API format (camelCase keys, nested `config`).
66+
"""
67+
api_streams: list[dict[str, Any]] = []
68+
69+
for entry in configured_catalog.get("streams", []):
70+
stream_info = entry.get("stream", {})
71+
72+
# Convert stream-level keys from snake_case → camelCase (shallow).
73+
api_stream = snake_to_camel_keys(stream_info)
74+
75+
# Everything except "stream" is a config field — nest back under "config".
76+
config = snake_to_camel_keys({k: v for k, v in entry.items() if k != "stream"})
77+
78+
api_streams.append({"stream": api_stream, "config": config})
79+
80+
return {"streams": api_streams}
81+
82+
83+
def _is_protocol_catalog_format(catalog: dict[str, Any]) -> bool:
84+
"""Detect whether a catalog dict is in Airbyte protocol format.
85+
86+
Checks the first stream entry for snake_case config keys (`sync_mode`)
87+
at the top level, which indicates protocol format. API format nests
88+
these under a `config` key with camelCase (`syncMode`).
89+
90+
Returns `True` for protocol format, `False` for Config API format.
91+
"""
92+
streams = catalog.get("streams", [])
93+
if not streams:
94+
return False
95+
96+
first = streams[0]
97+
# Protocol format has sync_mode at top level; API format has it inside config
98+
return "sync_mode" in first or "destination_sync_mode" in first

airbyte/cloud/_connection_state.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
from pydantic import BaseModel, ConfigDict, Field
1717

18+
from airbyte.cloud._case_conversion import snake_to_camel_keys
19+
1820

1921
class StreamDescriptor(BaseModel):
2022
"""Descriptor for a single stream within a connection state."""
@@ -107,6 +109,145 @@ def __str__(self) -> str:
107109
)
108110

109111

112+
def _normalize_state_to_protocol(
113+
raw_state: dict[str, Any],
114+
) -> list[dict[str, Any]]:
115+
"""Convert a raw Config API state blob to Airbyte protocol state messages.
116+
117+
Transforms the camelCase API format into a list of `AirbyteStateMessage` dicts
118+
with snake_case keys, suitable for passing to a connector's `--state` flag.
119+
120+
Args:
121+
raw_state: Full state dict from the Config API, including `stateType`.
122+
123+
Returns:
124+
List of protocol-format state message dicts. Empty list if state is `not_set`.
125+
"""
126+
parsed = ConnectionStateResponse(**raw_state)
127+
128+
if parsed.state_type == "not_set":
129+
return []
130+
131+
if parsed.state_type == "legacy":
132+
return [{"type": "LEGACY", "data": parsed.state or {}}]
133+
134+
if parsed.state_type == "global" and parsed.global_state:
135+
return [
136+
{
137+
"type": "GLOBAL",
138+
"global": parsed.global_state.model_dump(by_alias=False),
139+
}
140+
]
141+
142+
# state_type == "stream"
143+
if not parsed.stream_state:
144+
return []
145+
146+
return [
147+
{
148+
"type": "STREAM",
149+
"stream": entry.model_dump(by_alias=False),
150+
}
151+
for entry in parsed.stream_state
152+
]
153+
154+
155+
def _stream_entry_to_api(stream_entry: dict[str, Any]) -> dict[str, Any]:
156+
"""Convert a single protocol-format stream entry to Config API format.
157+
158+
Converts `stream_descriptor` keys to camelCase (via generic helper) and
159+
preserves the opaque `stream_state` blob as-is. Strips `None` values from
160+
the descriptor (the API omits null fields like `namespace`).
161+
"""
162+
descriptor = stream_entry.get("stream_descriptor", {})
163+
api_descriptor = {k: v for k, v in snake_to_camel_keys(descriptor).items() if v is not None}
164+
return {
165+
"streamDescriptor": api_descriptor,
166+
"streamState": stream_entry.get("stream_state"),
167+
}
168+
169+
170+
def _denormalize_protocol_state_to_api(
171+
protocol_messages: list[dict[str, Any]],
172+
connection_id: str,
173+
) -> dict[str, Any]:
174+
"""Convert Airbyte protocol state messages back to Config API format.
175+
176+
Reverses `_normalize_state_to_protocol`, producing a dict suitable for
177+
`import_raw_state()` / the Config API `create_or_update_safe` endpoint.
178+
Uses generic snake_case → camelCase key conversion for stream entries;
179+
the opaque state blobs are preserved as-is.
180+
181+
Args:
182+
protocol_messages: List of protocol-format `AirbyteStateMessage` dicts.
183+
connection_id: Connection ID to embed in the result.
184+
185+
Returns:
186+
A Config API state dict with camelCase keys and `stateType`.
187+
"""
188+
if not protocol_messages:
189+
return {
190+
"stateType": "not_set",
191+
"connectionId": connection_id,
192+
}
193+
194+
first = protocol_messages[0]
195+
msg_type = first.get("type", "").upper()
196+
197+
if msg_type == "LEGACY":
198+
return {
199+
"stateType": "legacy",
200+
"connectionId": connection_id,
201+
"state": first.get("data", {}),
202+
}
203+
204+
if msg_type == "GLOBAL":
205+
global_body = first.get("global", {})
206+
return {
207+
"stateType": "global",
208+
"connectionId": connection_id,
209+
"globalState": {
210+
"sharedState": global_body.get("shared_state"),
211+
"streamStates": [
212+
_stream_entry_to_api(s) for s in global_body.get("stream_states", [])
213+
],
214+
},
215+
}
216+
217+
# STREAM type
218+
return {
219+
"stateType": "stream",
220+
"connectionId": connection_id,
221+
"streamState": [_stream_entry_to_api(s.get("stream", {})) for s in protocol_messages],
222+
}
223+
224+
225+
def _is_protocol_state_format(
226+
state_input: dict[str, Any] | list[dict[str, Any]],
227+
) -> bool:
228+
"""Detect whether the input is in Airbyte protocol format.
229+
230+
Returns `True` for protocol format:
231+
- an empty list (representing no protocol state), or
232+
- a non-empty list where every item is a dict with a known `type`, or
233+
- a single dict with top-level snake_case `type` and no `stateType`.
234+
235+
Returns `False` for Config API format (for example, a dict with `stateType`
236+
or a raw `streamState` list from the Config API).
237+
"""
238+
protocol_message_types = {"STREAM", "GLOBAL", "LEGACY"}
239+
240+
if isinstance(state_input, list):
241+
if not state_input:
242+
return True
243+
return all(
244+
isinstance(message, dict) and message.get("type") in protocol_message_types
245+
for message in state_input
246+
)
247+
# A dict with snake_case 'type' at top-level is a single protocol message
248+
return "type" in state_input and "stateType" not in state_input
249+
250+
110251
def _match_stream(
111252
stream: StreamState,
112253
stream_name: str,

0 commit comments

Comments
 (0)