Skip to content

Commit fd21b86

Browse files
aldogonzalez8claudedevin-ai-integration[bot]
authored
feat: add scopes object array to declarative OAuth spec (#934)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 6876663 commit fd21b86

File tree

5 files changed

+203
-1
lines changed

5 files changed

+203
-1
lines changed

airbyte_cdk/models/airbyte_protocol.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,72 @@ class AirbyteStateMessage:
7575
destinationStats: Optional[AirbyteStateStats] = None # type: ignore [name-defined]
7676

7777

78+
# The following dataclasses have been redeclared to include scopes, optional_scopes,
79+
# and scopes_join_strategy fields that are used by declarative OAuth connectors.
80+
# The protocol model (OauthConnectorInputSpecification) does not include these fields,
81+
# so serpyco_rs silently drops them during deserialization. By overriding the model here
82+
# and cascading through OAuthConfigSpecification → AdvancedAuth → ConnectorSpecification,
83+
# the fields are preserved in the connector's spec output.
84+
# This follows the same override pattern used above for AirbyteStateBlob.
85+
@dataclass
86+
class OauthConnectorInputSpecification:
87+
consent_url: str
88+
access_token_url: str
89+
scope: Optional[str] = None
90+
scopes: Optional[List[Dict[str, Any]]] = None
91+
optional_scopes: Optional[List[Dict[str, Any]]] = None
92+
# Stored as str (not ScopesJoinStrategy enum) because spec.py converts the enum
93+
# to its .value before serialization. The protocol layer only sees plain strings.
94+
scopes_join_strategy: Optional[str] = None
95+
access_token_headers: Optional[Dict[str, Any]] = None
96+
access_token_params: Optional[Dict[str, Any]] = None
97+
extract_output: Optional[List[str]] = None
98+
state: Optional[State] = None # type: ignore [name-defined]
99+
client_id_key: Optional[str] = None
100+
client_secret_key: Optional[str] = None
101+
scope_key: Optional[str] = None
102+
state_key: Optional[str] = None
103+
auth_code_key: Optional[str] = None
104+
redirect_uri_key: Optional[str] = None
105+
token_expiry_key: Optional[str] = None
106+
107+
108+
@dataclass
109+
class OAuthConfigSpecification:
110+
oauth_user_input_from_connector_config_specification: Optional[Dict[str, Any]] = None
111+
oauth_connector_input_specification: Optional[OauthConnectorInputSpecification] = None
112+
complete_oauth_output_specification: Optional[Dict[str, Any]] = None
113+
complete_oauth_server_input_specification: Optional[Dict[str, Any]] = None
114+
complete_oauth_server_output_specification: Optional[Dict[str, Any]] = None
115+
116+
117+
@dataclass
118+
class AdvancedAuth:
119+
auth_flow_type: Optional[AuthFlowType] = None # type: ignore [name-defined]
120+
predicate_key: Optional[List[str]] = None
121+
predicate_value: Optional[str] = None
122+
oauth_config_specification: Optional[OAuthConfigSpecification] = None
123+
124+
125+
@dataclass
126+
class ConnectorSpecification:
127+
connectionSpecification: Dict[str, Any]
128+
documentationUrl: Optional[str] = None
129+
changelogUrl: Optional[str] = None
130+
supportsIncremental: Optional[bool] = None
131+
supportsNormalization: Optional[bool] = False
132+
supportsDBT: Optional[bool] = False
133+
supported_destination_sync_modes: Optional[List[DestinationSyncMode]] = None # type: ignore [name-defined]
134+
authSpecification: Optional[AuthSpecification] = None # type: ignore [name-defined]
135+
advanced_auth: Optional[AdvancedAuth] = None
136+
protocol_version: Optional[str] = None
137+
138+
78139
@dataclass
79140
class AirbyteMessage:
80141
type: Type # type: ignore [name-defined]
81142
log: Optional[AirbyteLogMessage] = None # type: ignore [name-defined]
82-
spec: Optional[ConnectorSpecification] = None # type: ignore [name-defined]
143+
spec: Optional[ConnectorSpecification] = None
83144
connectionStatus: Optional[AirbyteConnectionStatus] = None # type: ignore [name-defined]
84145
catalog: Optional[AirbyteCatalog] = None # type: ignore [name-defined]
85146
record: Optional[AirbyteRecordMessage] = None # type: ignore [name-defined]

airbyte_cdk/sources/declarative/declarative_component_schema.yaml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3064,6 +3064,55 @@ definitions:
30643064
The DeclarativeOAuth Specific string of the scopes needed to be grant for authenticated user.
30653065
examples:
30663066
- user:read user:read_orders workspaces:read
3067+
# NOTE: scopes, optional_scopes, and scopes_join_strategy are processed by the
3068+
# platform OAuth handler (DeclarativeOAuthSpecHandler.kt), not by the CDK runtime.
3069+
# The CDK schema defines the manifest contract; the platform reads these fields
3070+
# during the OAuth consent flow to build the authorization URL.
3071+
scopes:
3072+
title: Scopes
3073+
type: array
3074+
items:
3075+
type: object
3076+
required:
3077+
- scope
3078+
properties:
3079+
scope:
3080+
type: string
3081+
description: The OAuth scope string to request from the provider.
3082+
additionalProperties: true
3083+
description: |-
3084+
List of OAuth scope objects. When present, takes precedence over the `scope` string property.
3085+
The scope values are joined using the `scopes_join_strategy` (default: space) before being
3086+
sent to the OAuth provider.
3087+
examples:
3088+
- [{"scope": "user:read"}, {"scope": "user:write"}]
3089+
optional_scopes:
3090+
title: Optional Scopes
3091+
type: array
3092+
items:
3093+
type: object
3094+
required:
3095+
- scope
3096+
properties:
3097+
scope:
3098+
type: string
3099+
description: The OAuth scope string to request from the provider.
3100+
additionalProperties: true
3101+
description: |-
3102+
Optional OAuth scope objects that may or may not be granted.
3103+
examples:
3104+
- [{"scope": "admin:read"}]
3105+
scopes_join_strategy:
3106+
title: Scopes Join Strategy
3107+
type: string
3108+
enum:
3109+
- space
3110+
- comma
3111+
- plus
3112+
default: space
3113+
description: |-
3114+
The strategy used to join the `scopes` array into a single string for the OAuth request.
3115+
Defaults to `space` per RFC 6749.
30673116
access_token_url:
30683117
title: Access Token URL
30693118
type: string

airbyte_cdk/sources/declarative/models/declarative_component_schema.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ class AuthFlowType(Enum):
2020
oauth1_0 = "oauth1.0"
2121

2222

23+
class ScopesJoinStrategy(Enum):
24+
space = "space"
25+
comma = "comma"
26+
plus = "plus"
27+
28+
2329
class BasicHttpAuthenticator(BaseModel):
2430
type: Literal["BasicHttpAuthenticator"]
2531
username: str = Field(
@@ -827,6 +833,16 @@ class Config:
827833
max: int
828834

829835

836+
class OAuthScope(BaseModel):
837+
class Config:
838+
extra = Extra.allow
839+
840+
scope: str = Field(
841+
...,
842+
description="The OAuth scope string to request from the provider.",
843+
)
844+
845+
830846
class OauthConnectorInputSpecification(BaseModel):
831847
class Config:
832848
extra = Extra.allow
@@ -846,6 +862,27 @@ class Config:
846862
examples=["user:read user:read_orders workspaces:read"],
847863
title="Scopes",
848864
)
865+
# NOTE: scopes, optional_scopes, and scopes_join_strategy are processed by the
866+
# platform OAuth handler (DeclarativeOAuthSpecHandler.kt), not by the CDK runtime.
867+
# The CDK schema defines the manifest contract; the platform reads these fields
868+
# during the OAuth consent flow to build the authorization URL.
869+
scopes: Optional[List[OAuthScope]] = Field(
870+
None,
871+
description="List of OAuth scope objects. When present, takes precedence over the `scope` string property.\nThe scope values are joined using the `scopes_join_strategy` (default: space) before being\nsent to the OAuth provider.",
872+
examples=[[{"scope": "user:read"}, {"scope": "user:write"}]],
873+
title="Scopes",
874+
)
875+
optional_scopes: Optional[List[OAuthScope]] = Field(
876+
None,
877+
description="Optional OAuth scope objects that may or may not be granted.",
878+
examples=[[{"scope": "admin:read"}]],
879+
title="Optional Scopes",
880+
)
881+
scopes_join_strategy: Optional[ScopesJoinStrategy] = Field(
882+
ScopesJoinStrategy.space,
883+
description="The strategy used to join the `scopes` array into a single string for the OAuth request.\nDefaults to `space` per RFC 6749.",
884+
title="Scopes Join Strategy",
885+
)
849886
access_token_url: str = Field(
850887
...,
851888
description="The DeclarativeOAuth Specific URL templated string to obtain the `access_token`, `refresh_token` etc.\nThe placeholders are replaced during the processing to provide neccessary values.",

airbyte_cdk/sources/declarative/spec/spec.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ def generate_spec(self) -> ConnectorSpecification:
5555
obj["documentationUrl"] = self.documentation_url
5656
if self.advanced_auth:
5757
self.advanced_auth.auth_flow_type = self.advanced_auth.auth_flow_type.value # type: ignore # We know this is always assigned to an AuthFlow which has the auth_flow_type field
58+
# Convert scopes_join_strategy enum to its string value (same pattern as auth_flow_type above)
59+
oauth_spec = getattr(self.advanced_auth, "oauth_config_specification", None)
60+
if oauth_spec:
61+
oauth_input = getattr(oauth_spec, "oauth_connector_input_specification", None)
62+
if (
63+
oauth_input
64+
and hasattr(oauth_input, "scopes_join_strategy")
65+
and oauth_input.scopes_join_strategy is not None
66+
):
67+
oauth_input.scopes_join_strategy = oauth_input.scopes_join_strategy.value # type: ignore
5868
# Map CDK AuthFlow model to protocol AdvancedAuth model
5969
obj["advanced_auth"] = self.advanced_auth.dict()
6070

unit_tests/sources/declarative/spec/test_spec.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
33
#
44

5+
import dataclasses
6+
57
import pytest
68

79
from airbyte_cdk.models import (
@@ -131,6 +133,7 @@
131133
oauth_connector_input_specification=model_declarative_oauth_connector_input_spec(
132134
consent_url="https://domain.host.com/endpoint/oauth?{client_id_key}={{client_id_key}}&{redirect_uri_key}={urlEncoder:{{redirect_uri_key}}}&{state_key}={{state_key}}",
133135
scope="reports:read campaigns:read",
136+
scopes_join_strategy="space", # default from component schema, preserved through protocol override
134137
access_token_headers={"Content-Type": "application/json"},
135138
access_token_params={"{auth_code_key}": "{{auth_code_key}}"},
136139
access_token_url="https://domain.host.com/endpoint/v1/oauth2/access_token/",
@@ -270,3 +273,45 @@ def test_given_invalid_config_value_when_validating_then_exception_is_raised() -
270273

271274
with pytest.raises(Exception):
272275
spec.validate_config(input_config)
276+
277+
278+
@pytest.mark.parametrize(
279+
"upstream_class_name, override_class_name",
280+
[
281+
("OauthConnectorInputSpecification", "OauthConnectorInputSpecification"),
282+
("OAuthConfigSpecification", "OAuthConfigSpecification"),
283+
("AdvancedAuth", "AdvancedAuth"),
284+
("ConnectorSpecification", "ConnectorSpecification"),
285+
],
286+
ids=[
287+
"OauthConnectorInputSpecification",
288+
"OAuthConfigSpecification",
289+
"AdvancedAuth",
290+
"ConnectorSpecification",
291+
],
292+
)
293+
def test_protocol_override_fields_in_sync(
294+
upstream_class_name: str, override_class_name: str
295+
) -> None:
296+
"""Ensure protocol override dataclasses stay compatible with their upstream counterparts.
297+
298+
The airbyte_protocol.py file redeclares several dataclasses to add fields that the
299+
upstream airbyte_protocol_dataclasses package does not yet include (e.g. scopes,
300+
optional_scopes, scopes_join_strategy). If the upstream package adds new fields, this
301+
test will fail to remind us to update the local overrides.
302+
"""
303+
import airbyte_protocol_dataclasses.models as upstream_models
304+
305+
import airbyte_cdk.models.airbyte_protocol as override_models
306+
307+
upstream_cls = getattr(upstream_models, upstream_class_name)
308+
override_cls = getattr(override_models, override_class_name)
309+
310+
upstream_fields = {f.name for f in dataclasses.fields(upstream_cls)}
311+
override_fields = {f.name for f in dataclasses.fields(override_cls)}
312+
313+
missing = upstream_fields - override_fields
314+
assert not missing, (
315+
f"Upstream protocol added fields {missing} to {upstream_class_name} "
316+
f"that are missing from the airbyte_protocol.py override. Update the override to match."
317+
)

0 commit comments

Comments
 (0)