-
Notifications
You must be signed in to change notification settings - Fork 4.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Connector Facebook-Marketing: update insights streams with custom entries for fields, breakdowns and action_breakdowns #4864
Changes from 11 commits
1fa4461
4cf644e
ab6fc13
2601654
b9dfed8
2338a0f
892279f
528cd44
b47be69
8c0f243
8dcf971
20df1e0
dd00338
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
{ | ||
"properties": { | ||
"action_device": {"type": ["null", "string"]}, | ||
"action_canvas_component_name": {"type": ["null", "string"]}, | ||
"action_carousel_card_id": {"type": ["null", "string"]}, | ||
"action_carousel_card_name": {"type": ["null", "string"]}, | ||
"action_destination": {"type": ["null", "string"]}, | ||
"action_reaction": {"type": ["null", "string"]}, | ||
"action_target_id": {"type": ["null", "string"]}, | ||
"action_type": {"type": ["null", "string"]}, | ||
"action_video_sound": {"type": ["null", "string"]}, | ||
"action_video_type": {"type": ["null", "string"]}, | ||
"ad_format_asset": {"type": ["null", "string"]}, | ||
"age": {"type": ["null", "string"]}, | ||
"app_id": {"type": ["null", "string"]}, | ||
"body_asset": {"type": ["null", "string"]}, | ||
"call_to_action_asset": {"type": ["null", "string"]}, | ||
"country": {"type": ["null", "string"]}, | ||
"description_asset": {"type": ["null", "string"]}, | ||
"device_platform": {"type": ["null", "string"]}, | ||
"dma": {"type": ["null", "string"]}, | ||
"frequency_value": {"type": ["null", "string"]}, | ||
"gender": {"type": ["null", "string"]}, | ||
"hourly_stats_aggregated_by_advertiser_time_zone": {"type": ["null", "string"]}, | ||
"hourly_stats_aggregated_by_audience_time_zone": {"type": ["null", "string"]}, | ||
"image_asset": {"type": ["null", "string"]}, | ||
"impression_device": {"type": ["null", "string"]}, | ||
"link_url_asset": {"type": ["null", "string"]}, | ||
"place_page_id": {"type": ["null", "string"]}, | ||
"platform_position": {"type": ["null", "string"]}, | ||
"product_id": {"type": ["null", "string"]}, | ||
"publisher_platform": {"type": ["null", "string"]}, | ||
"region": {"type": ["null", "string"]}, | ||
"skan_conversion_id": {"type": ["null", "string"]}, | ||
"title_asset": {"type": ["null", "string"]}, | ||
"video_asset": {"type": ["null", "string"]} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,13 +2,22 @@ | |
# Copyright (c) 2021 Airbyte, Inc., all rights reserved. | ||
# | ||
|
||
import json | ||
from datetime import datetime | ||
from typing import Any, List, Mapping, Optional, Tuple, Type, MutableMapping | ||
from jsonschema import RefResolver | ||
|
||
from airbyte_cdk.entrypoint import logger | ||
from airbyte_cdk.logger import AirbyteLogger | ||
from airbyte_cdk.models import AirbyteConnectionStatus, AuthSpecification, ConnectorSpecification, DestinationSyncMode, OAuth2Specification, Status | ||
|
||
from typing import Any, List, Mapping, Optional, Tuple, Type | ||
|
||
import pendulum | ||
from airbyte_cdk.models import AuthSpecification, ConnectorSpecification, DestinationSyncMode, OAuth2Specification | ||
from airbyte_cdk.sources import AbstractSource | ||
from airbyte_cdk.sources.streams import Stream | ||
from airbyte_cdk.sources.streams.core import package_name_from_class | ||
from airbyte_cdk.sources.utils.schema_helpers import ResourceSchemaLoader | ||
from pydantic import BaseModel, Field | ||
from source_facebook_marketing.api import API | ||
from source_facebook_marketing.streams import ( | ||
|
@@ -26,6 +35,17 @@ | |
) | ||
|
||
|
||
class InsightConfig(BaseModel): | ||
|
||
name: str = Field(description="The name value of insight") | ||
|
||
fields: Optional[List[str]] = Field(description="A list of chosen fields for fields parameter", default=[]) | ||
|
||
breakdowns: Optional[List[str]] = Field(description="A list of chosen breakdowns for breakdowns", default=[]) | ||
|
||
action_breakdowns: Optional[List[str]] = Field(description="A list of chosen action_breakdowns for action_breakdowns", default=[]) | ||
|
||
|
||
class ConnectorConfig(BaseModel): | ||
class Config: | ||
title = "Source Facebook Marketing" | ||
|
@@ -65,6 +85,9 @@ class Config: | |
minimum=1, | ||
maximum=30, | ||
) | ||
insights: Optional[List[InsightConfig]] = Field( | ||
vladimir-remar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
description="A list wich contains insights entries, each entry must have a name and can contains fields, breakdowns or action_breakdowns)" | ||
) | ||
|
||
|
||
class SourceFacebookMarketing(AbstractSource): | ||
|
@@ -104,10 +127,11 @@ def streams(self, config: Mapping[str, Any]) -> List[Type[Stream]]: | |
days_per_job=config.insights_days_per_job, | ||
) | ||
|
||
return [ | ||
streams = [ | ||
Campaigns(api=api, start_date=config.start_date, end_date=config.end_date, include_deleted=config.include_deleted), | ||
AdSets(api=api, start_date=config.start_date, end_date=config.end_date, include_deleted=config.include_deleted), | ||
Ads(api=api, start_date=config.start_date, end_date=config.end_date, include_deleted=config.include_deleted), | ||
|
||
AdCreatives(api=api), | ||
AdsInsights(**insights_args), | ||
AdsInsightsAgeAndGender(**insights_args), | ||
|
@@ -118,6 +142,22 @@ def streams(self, config: Mapping[str, Any]) -> List[Type[Stream]]: | |
AdsInsightsActionType(**insights_args), | ||
] | ||
|
||
return self._update_insights_streams(insights=config.insights, args=insights_args, streams=streams) | ||
|
||
def check(self, logger: AirbyteLogger, config: Mapping[str, Any]) -> AirbyteConnectionStatus: | ||
"""Implements the Check Connection operation from the Airbyte Specification. See https://docs.airbyte.io/architecture/airbyte-specification.""" | ||
try: | ||
check_succeeded, error = self.check_connection(logger, config) | ||
if not check_succeeded: | ||
return AirbyteConnectionStatus(status=Status.FAILED, message=repr(error)) | ||
except Exception as e: | ||
return AirbyteConnectionStatus(status=Status.FAILED, message=repr(e)) | ||
|
||
self._check_insights_entries(config.get('insights', [])) | ||
|
||
return AirbyteConnectionStatus(status=Status.SUCCEEDED) | ||
|
||
|
||
def spec(self, *args, **kwargs) -> ConnectorSpecification: | ||
""" | ||
Returns the spec for this integration. The spec is a JSON-Schema object describing the required configurations (e.g: username and password) | ||
|
@@ -128,11 +168,73 @@ def spec(self, *args, **kwargs) -> ConnectorSpecification: | |
changelogUrl="https://docs.airbyte.io/integrations/sources/facebook-marketing", | ||
supportsIncremental=True, | ||
supported_destination_sync_modes=[DestinationSyncMode.append], | ||
connectionSpecification=ConnectorConfig.schema(), | ||
connectionSpecification=expand_local_ref(ConnectorConfig.schema()), | ||
authSpecification=AuthSpecification( | ||
auth_type="oauth2.0", | ||
oauth2Specification=OAuth2Specification( | ||
rootObject=[], oauthFlowInitParameters=[], oauthFlowOutputParameters=[["access_token"]] | ||
), | ||
) | ||
), | ||
) | ||
|
||
def _update_insights_streams(self, insights, args, streams) -> List[Type[Stream]]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suggest the following approach for these custom streams:
This way it is very very obvious to the user what is happening. This is especially important as the connector's config is updated over time e.g: a user might call a stream There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added the change in the lastest commit |
||
"""Update method, if insights have values returns streams replacing the | ||
default insights streams else returns streams | ||
|
||
""" | ||
if not insights: | ||
return streams | ||
|
||
insights_custom_streams = list() | ||
|
||
for insight in insights: | ||
args["name"] = f"Custom{insight.name}" | ||
args["fields"] = list(set(insight.fields)) | ||
args["breakdowns"] = list(set(insight.breakdowns)) | ||
args["action_breakdowns"] = list(set(insight.action_breakdowns)) | ||
insight_stream = AdsInsights(**args) | ||
insights_custom_streams.append(insight_stream) | ||
|
||
return streams + insights_custom_streams | ||
|
||
def _check_insights_entries(self, insights): | ||
vladimir-remar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
default_fields = list(ResourceSchemaLoader(package_name_from_class(self.__class__)).get_schema("ads_insights").get("properties", {}).keys()) | ||
default_breakdowns = list(ResourceSchemaLoader(package_name_from_class(self.__class__)).get_schema("ads_insights_breakdowns").get("properties", {}).keys()) | ||
default_actions_breakdowns = [e for e in default_breakdowns if 'action_' in e] | ||
|
||
for insight in insights: | ||
vladimir-remar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if insight.get('fields') and not self._check_values(default_fields, insight.get('fields')): | ||
message = f"For {insight.get('name')} that field are not espected in fields" | ||
vladimir-remar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
raise Exception("Config validation error: " + message) from None | ||
if insight.get('breakdowns') and not self._check_values(default_breakdowns, insight.get('breakdowns')): | ||
message = f"For {insight.get('name')} that breakdown are not espected in breakdowns" | ||
raise Exception("Config validation error: " + message) from None | ||
if insight.get('action_breakdowns') and not self._check_values(default_actions_breakdowns, insight.get('action_breakdowns')): | ||
message = f"For {insight.get('name')} that action_breakdowns are not espected in action_breakdowns" | ||
raise Exception("Config validation error: " + message) from None | ||
|
||
return True | ||
|
||
def _check_values(self, default_value: List[str], custom_value: List[str]) -> bool: | ||
for e in custom_value: | ||
if e not in default_value: | ||
logger.error(f"{e} does not appers in {default_value}") | ||
vladimir-remar marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return False | ||
return True | ||
|
||
|
||
def expand_local_ref(schema, resolver=None, **kwargs): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @sherifnada thanks to @keu |
||
resolver = resolver or RefResolver("", schema) | ||
if isinstance(schema, MutableMapping): | ||
if "$ref" in schema: | ||
ref_url = schema.pop("$ref") | ||
url, resolved_schema = resolver.resolve(ref_url) | ||
schema.update(resolved_schema) | ||
for key, value in schema.items(): | ||
schema[key] = expand_local_ref(value, resolver=resolver) | ||
return schema | ||
elif isinstance(schema, List): | ||
return [expand_local_ref(item, resolver=resolver) for item in schema] | ||
|
||
return schema |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@vladimir-remar did you validate that the json output by
spec
works with the UI via the instructions here? Just checking because I don't remember if we support a list of objectsThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@sherifnada we do actually :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@sherifnada thanks to @keu, I attached some images from UI
It will be filled like this
And it will look like this with two elements
and from destination side