diff --git a/airbyte-config/init/src/main/resources/config/STANDARD_SOURCE_DEFINITION/7b86879e-26c5-4ef6-a5ce-2be5c7b46d1e.json b/airbyte-config/init/src/main/resources/config/STANDARD_SOURCE_DEFINITION/7b86879e-26c5-4ef6-a5ce-2be5c7b46d1e.json new file mode 100644 index 000000000000..81011b6ca847 --- /dev/null +++ b/airbyte-config/init/src/main/resources/config/STANDARD_SOURCE_DEFINITION/7b86879e-26c5-4ef6-a5ce-2be5c7b46d1e.json @@ -0,0 +1,7 @@ +{ + "sourceDefinitionId": "7b86879e-26c5-4ef6-a5ce-2be5c7b46d1e", + "name": "Linnworks", + "dockerRepository": "airbyte/source-linnworks", + "dockerImageTag": "0.1.0", + "documentationUrl": "https://docs.airbyte.io/integrations/sources/linnworks" +} diff --git a/airbyte-config/init/src/main/resources/seed/source_definitions.yaml b/airbyte-config/init/src/main/resources/seed/source_definitions.yaml index 07a237422c19..ad68e3b4db49 100644 --- a/airbyte-config/init/src/main/resources/seed/source_definitions.yaml +++ b/airbyte-config/init/src/main/resources/seed/source_definitions.yaml @@ -302,6 +302,12 @@ dockerImageTag: 0.1.2 documentationUrl: https://docs.airbyte.io/integrations/sources/linkedin-ads sourceType: api +- name: Linnworks + sourceDefinitionId: 7b86879e-26c5-4ef6-a5ce-2be5c7b46d1e + dockerRepository: airbyte/source-linnworks + dockerImageTag: 0.1.1 + documentationUrl: https://docs.airbyte.io/integrations/sources/linnworks + sourceType: api - name: Looker sourceDefinitionId: 00405b19-9768-4e0c-b1ae-9fc2ee2b2a8c dockerRepository: airbyte/source-looker diff --git a/airbyte-config/init/src/main/resources/seed/source_specs.yaml b/airbyte-config/init/src/main/resources/seed/source_specs.yaml index 23ff1c19a379..8fb94172225c 100644 --- a/airbyte-config/init/src/main/resources/seed/source_specs.yaml +++ b/airbyte-config/init/src/main/resources/seed/source_specs.yaml @@ -3082,6 +3082,37 @@ supportsNormalization: false supportsDBT: false supported_destination_sync_modes: [] +- dockerImage: "airbyte/source-linnworks:0.1.1" + spec: + documentationUrl: "https://docsurl.com" + connectionSpecification: + $schema: "http://json-schema.org/draft-07/schema#" + title: "Linnworks Spec" + type: "object" + required: + - "application_id" + - "application_secret" + - "token" + - "start_date" + additionalProperties: false + properties: + application_id: + title: "Application ID" + type: "string" + application_secret: + title: "Application secret" + type: "string" + airbyte_secret: true + token: + title: "Token" + type: "string" + start_date: + title: "Start date" + type: "string" + format: "date-time" + supportsNormalization: false + supportsDBT: false + supported_destination_sync_modes: [] - dockerImage: "airbyte/source-looker:0.2.5" spec: documentationUrl: "https://docs.airbyte.io/integrations/sources/looker" diff --git a/airbyte-integrations/builds.md b/airbyte-integrations/builds.md index ca0e137d106d..ba0415b80067 100644 --- a/airbyte-integrations/builds.md +++ b/airbyte-integrations/builds.md @@ -41,6 +41,7 @@ | Iterable | [![source-iterable](https://img.shields.io/endpoint?url=https%3A%2F%2Fdnsgjos7lj2fu.cloudfront.net%2Ftests%2Fsummary%2Fsource-iterable%2Fbadge.json)](https://dnsgjos7lj2fu.cloudfront.net/tests/summary/source-iterable) | | Jira | [![source-jira](https://img.shields.io/endpoint?url=https%3A%2F%2Fdnsgjos7lj2fu.cloudfront.net%2Ftests%2Fsummary%2Fsource-jira%2Fbadge.json)](https://dnsgjos7lj2fu.cloudfront.net/tests/summary/source-jira) | | LinkedIn Ads | [![source-linkedin-ads](https://img.shields.io/endpoint?url=https%3A%2F%2Fdnsgjos7lj2fu.cloudfront.net%2Ftests%2Fsummary%2Fsource-linkedin-ads%2Fbadge.json)](https://dnsgjos7lj2fu.cloudfront.net/tests/summary/source-linkedin-ads) | +| Linnworks | [![source-linnworks](https://img.shields.io/endpoint?url=https%3A%2F%2Fdnsgjos7lj2fu.cloudfront.net%2Ftests%2Fsummary%2Fsource-linnworks%2Fbadge.json)](https://dnsgjos7lj2fu.cloudfront.net/tests/summary/source-linnworks) | | Lever Hiring | [![source-lever-hiring](https://img.shields.io/endpoint?url=https%3A%2F%2Fdnsgjos7lj2fu.cloudfront.net%2Ftests%2Fsummary%2Fsource-lever-hiring%2Fbadge.json)](https://dnsgjos7lj2fu.cloudfront.net/tests/summary/source-lever-hiring) | | Looker | [![source-looker](https://img.shields.io/endpoint?url=https%3A%2F%2Fdnsgjos7lj2fu.cloudfront.net%2Ftests%2Fsummary%2Fsource-looker%2Fbadge.json)](https://dnsgjos7lj2fu.cloudfront.net/tests/summary/source-looker) | | Kafka | [![source-kafka](https://img.shields.io/endpoint?url=https%3A%2F%2Fdnsgjos7lj2fu.cloudfront.net%2Ftests%2Fsummary%2Fsource-kafka%2Fbadge.json)](https://dnsgjos7lj2fu.cloudfront.net/tests/summary/source-kafka) | diff --git a/airbyte-integrations/connectors/source-linnworks/.dockerignore b/airbyte-integrations/connectors/source-linnworks/.dockerignore new file mode 100644 index 000000000000..a78d07826ae1 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/.dockerignore @@ -0,0 +1,7 @@ +* +!Dockerfile +!Dockerfile.test +!main.py +!source_linnworks +!setup.py +!secrets diff --git a/airbyte-integrations/connectors/source-linnworks/Dockerfile b/airbyte-integrations/connectors/source-linnworks/Dockerfile new file mode 100644 index 000000000000..0461d77635ec --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/Dockerfile @@ -0,0 +1,38 @@ +FROM python:3.7.11-alpine3.14 as base + +# build and load all requirements +FROM base as builder +WORKDIR /airbyte/integration_code + +# upgrade pip to the latest version +RUN apk --no-cache upgrade \ + && pip install --upgrade pip \ + && apk --no-cache add tzdata build-base + + +COPY setup.py ./ +# install necessary packages to a temporary folder +RUN pip install --prefix=/install . + +# build a clean environment +FROM base +WORKDIR /airbyte/integration_code + +# copy all loaded and built libraries to a pure basic image +COPY --from=builder /install /usr/local +# add default timezone settings +COPY --from=builder /usr/share/zoneinfo/Etc/UTC /etc/localtime +RUN echo "Etc/UTC" > /etc/timezone + +# bash is installed for more convenient debugging. +RUN apk --no-cache add bash + +# copy payload code only +COPY main.py ./ +COPY source_linnworks ./source_linnworks + +ENV AIRBYTE_ENTRYPOINT "python /airbyte/integration_code/main.py" +ENTRYPOINT ["python", "/airbyte/integration_code/main.py"] + +LABEL io.airbyte.version=0.1.1 +LABEL io.airbyte.name=airbyte/source-linnworks diff --git a/airbyte-integrations/connectors/source-linnworks/README.md b/airbyte-integrations/connectors/source-linnworks/README.md new file mode 100644 index 000000000000..fa397d9d9c11 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/README.md @@ -0,0 +1,132 @@ +# Linnworks Source + +This is the repository for the Linnworks source connector, written in Python. +For information about how to use this connector within Airbyte, see [the documentation](https://docs.airbyte.io/integrations/sources/linnworks). + +## Local development + +### Prerequisites +**To iterate on this connector, make sure to complete this prerequisites section.** + +#### Minimum Python version required `= 3.7.0` + +#### Build & Activate Virtual Environment and install dependencies +From this connector directory, create a virtual environment: +``` +python -m venv .venv +``` + +This will generate a virtualenv for this module in `.venv/`. Make sure this venv is active in your +development environment of choice. To activate it from the terminal, run: +``` +source .venv/bin/activate +pip install -r requirements.txt +pip install '.[tests]' +``` +If you are in an IDE, follow your IDE's instructions to activate the virtualenv. + +Note that while we are installing dependencies from `requirements.txt`, you should only edit `setup.py` for your dependencies. `requirements.txt` is +used for editable installs (`pip install -e`) to pull in Python dependencies from the monorepo and will call `setup.py`. +If this is mumbo jumbo to you, don't worry about it, just put your deps in `setup.py` but install using `pip install -r requirements.txt` and everything +should work as you expect. + +#### Building via Gradle +You can also build the connector in Gradle. This is typically used in CI and not needed for your development workflow. + +To build using Gradle, from the Airbyte repository root, run: +``` +./gradlew :airbyte-integrations:connectors:source-linnworks:build +``` + +#### Create credentials +**If you are a community contributor**, follow the instructions in the [documentation](https://docs.airbyte.io/integrations/sources/linnworks) +to generate the necessary credentials. Then create a file `secrets/config.json` conforming to the `source_linnworks/spec.json` file. +Note that any directory named `secrets` is gitignored across the entire Airbyte repo, so there is no danger of accidentally checking in sensitive information. +See `integration_tests/sample_config.json` for a sample config file. + +**If you are an Airbyte core member**, copy the credentials in Lastpass under the secret name `source linnworks test creds` +and place them into `secrets/config.json`. + +### Locally running the connector +``` +python main.py spec +python main.py check --config secrets/config.json +python main.py discover --config secrets/config.json +python main.py read --config secrets/config.json --catalog integration_tests/configured_catalog.json +``` + +### Locally running the connector docker image + +#### Build +First, make sure you build the latest Docker image: +``` +docker build . -t airbyte/source-linnworks:dev +``` + +You can also build the connector image via Gradle: +``` +./gradlew :airbyte-integrations:connectors:source-linnworks:airbyteDocker +``` +When building via Gradle, the docker image name and tag, respectively, are the values of the `io.airbyte.name` and `io.airbyte.version` `LABEL`s in +the Dockerfile. + +#### Run +Then run any of the connector commands as follows: +``` +docker run --rm airbyte/source-linnworks:dev spec +docker run --rm -v $(pwd)/secrets:/secrets airbyte/source-linnworks:dev check --config /secrets/config.json +docker run --rm -v $(pwd)/secrets:/secrets airbyte/source-linnworks:dev discover --config /secrets/config.json +docker run --rm -v $(pwd)/secrets:/secrets -v $(pwd)/integration_tests:/integration_tests airbyte/source-linnworks:dev read --config /secrets/config.json --catalog /integration_tests/configured_catalog.json +``` +## Testing +Make sure to familiarize yourself with [pytest test discovery](https://docs.pytest.org/en/latest/goodpractices.html#test-discovery) to know how your test files and methods should be named. +First install test dependencies into your virtual environment: +``` +pip install .[tests] +``` +### Unit Tests +To run unit tests locally, from the connector directory run: +``` +python -m pytest unit_tests +``` + +### Integration Tests +There are two types of integration tests: Acceptance Tests (Airbyte's test suite for all source connectors) and custom integration tests (which are specific to this connector). +#### Custom Integration tests +Place custom tests inside `integration_tests/` folder, then, from the connector root, run +``` +python -m pytest integration_tests +``` +#### Acceptance Tests +Customize `acceptance-test-config.yml` file to configure tests. See [Source Acceptance Tests](https://docs.airbyte.io/connector-development/testing-connectors/source-acceptance-tests-reference) for more information. +If your connector requires to create or destroy resources for use during acceptance tests create fixtures for it and place them inside integration_tests/acceptance.py. +To run your integration tests with acceptance tests, from the connector root, run +``` +python -m pytest integration_tests -p integration_tests.acceptance +``` +To run your integration tests with docker + +### Using gradle to run tests +All commands should be run from airbyte project root. +To run unit tests: +``` +./gradlew :airbyte-integrations:connectors:source-linnworks:unitTest +``` +To run acceptance and custom integration tests: +``` +./gradlew :airbyte-integrations:connectors:source-linnworks:integrationTest +``` + +## Dependency Management +All of your dependencies should go in `setup.py`, NOT `requirements.txt`. The requirements file is only used to connect internal Airbyte dependencies in the monorepo for local development. +We split dependencies between two groups, dependencies that are: +* required for your connector to work need to go to `MAIN_REQUIREMENTS` list. +* required for the testing need to go to `TEST_REQUIREMENTS` list + +### Publishing a new version of the connector +You've checked out the repo, implemented a million dollar feature, and you're ready to share your changes with the world. Now what? +1. Make sure your changes are passing unit and integration tests. +1. Bump the connector version in `Dockerfile` -- just increment the value of the `LABEL io.airbyte.version` appropriately (we use [SemVer](https://semver.org/)). +1. Create a Pull Request. +1. Pat yourself on the back for being an awesome contributor. +1. Someone from Airbyte will take a look at your PR and iterate with you to merge it into master. diff --git a/airbyte-integrations/connectors/source-linnworks/acceptance-test-config.yml b/airbyte-integrations/connectors/source-linnworks/acceptance-test-config.yml new file mode 100644 index 000000000000..d7cd5759ed08 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/acceptance-test-config.yml @@ -0,0 +1,24 @@ +# See [Source Acceptance Tests](https://docs.airbyte.io/connector-development/testing-connectors/source-acceptance-tests-reference) +# for more information about how to configure these tests +connector_image: airbyte/source-linnworks:dev +tests: + spec: + - spec_path: "source_linnworks/spec.json" + connection: + - config_path: "secrets/config.json" + status: "succeed" + - config_path: "integration_tests/invalid_config.json" + status: "failed" + discovery: + - config_path: "secrets/config.json" + basic_read: + - config_path: "secrets/config.json" + configured_catalog_path: "integration_tests/configured_catalog.json" + empty_streams: [] + incremental: + - config_path: "secrets/config.json" + configured_catalog_path: "integration_tests/configured_catalog.json" + future_state_path: "integration_tests/abnormal_state.json" + full_refresh: + - config_path: "secrets/config.json" + configured_catalog_path: "integration_tests/configured_catalog.json" diff --git a/airbyte-integrations/connectors/source-linnworks/acceptance-test-docker.sh b/airbyte-integrations/connectors/source-linnworks/acceptance-test-docker.sh new file mode 100644 index 000000000000..e4d8b1cef896 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/acceptance-test-docker.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +# Build latest connector image +docker build . -t $(cat acceptance-test-config.yml | grep "connector_image" | head -n 1 | cut -d: -f2) + +# Pull latest acctest image +docker pull airbyte/source-acceptance-test:latest + +# Run +docker run --rm -it \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v /tmp:/tmp \ + -v $(pwd):/test_input \ + airbyte/source-acceptance-test \ + --acceptance-test-config /test_input + diff --git a/airbyte-integrations/connectors/source-linnworks/bootstrap.md b/airbyte-integrations/connectors/source-linnworks/bootstrap.md new file mode 100644 index 000000000000..444a380d0c79 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/bootstrap.md @@ -0,0 +1,25 @@ +# Linnworks + +Linnworks is an e-commerce sales channel and fulfillment integration platform. + +The platform has two portals: seller and developer. First, to create API credentials, log in to the [developer portal](https://developer.linnworks.com) and create an application of type `System Integration`. Then click on provided Installation URL and proceed with an installation wizard. The wizard will show a token that you will need for authentication. The installed application will be present on your account on [seller portal](https://login.linnworks.net/). + +Authentication credentials can be obtained on developer portal section Applications -> _Your application name_ -> Edit -> General. And the token, if you missed it during the install, can be obtained anytime under the section Applications -> _Your application name_ -> Installs. + +Authentication flow is similar to OAuth2. The only notable difference is that the authentication endpoint returns a dynamic API server URL that is later used for subsequent requests. + +For paginated results, all streams use max page size. Upstream pagination type [GenericPagedResult](https://apps.linnworks.net/Api/Class/linnworks-spa-commondata-Generic-GenericPagedResult) is implemented in class `LinnworksGenericPagedResult`. However, some endpoints use ad-hoc pagination styles, which are implemented directly in respective streams. + +The API uses a standard HTTP 429 status code and `Retry-After` header for rate limiting. Its value is used for exponential backoff. + +Linnworks API design is somewhat inconsistent and doesn't follow REST practice for providing uniform endpoints for every resource and collection of the resources. For example, collection endpoint sometimes returns only a part of resource attributes while specific resource endpoint returns all of them. In this case, N+1 requests are the only way to retrieve all attributes of all the resources of the same kind. + +## Processed Orders + +ProcessedOrders stream emits variable-length slice time intervals depending on the sync period. Too short, e.g., hourly interval severely reduces initial sync performance by issuing too many requests. On the other hand, too long, e.g., yearly, prevents the creation of state events. + +The optimal slice time interval should yield the number of records equal to the max page size, i.e., 500. In such a case, the stream would emit a state event after each HTTP request, minimizing the number of requests and preventing repeated fetch of already fetched data in case of failure or scheduled syncs. + +However, the slice time interval highly depends on the nature of upstream data and may substantially vary between different accounts. For example, consider one luxury items seller who sells a dozen items every week and another who sells thousands of items each day. The number of their processed orders in any time interval is several orders of magnitude apart. + +Current intervals are chosen purely speculatively. Therefore, they might be inappropriate for some sellers and would need adjustment. diff --git a/airbyte-integrations/connectors/source-linnworks/build.gradle b/airbyte-integrations/connectors/source-linnworks/build.gradle new file mode 100644 index 000000000000..814d35b569a5 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/build.gradle @@ -0,0 +1,14 @@ +plugins { + id 'airbyte-python' + id 'airbyte-docker' + id 'airbyte-source-acceptance-test' +} + +airbytePython { + moduleDirectory 'source_linnworks' +} + +dependencies { + implementation files(project(':airbyte-integrations:bases:source-acceptance-test').airbyteDocker.outputs) + implementation files(project(':airbyte-integrations:bases:base-python').airbyteDocker.outputs) +} diff --git a/airbyte-integrations/connectors/source-linnworks/integration_tests/__init__.py b/airbyte-integrations/connectors/source-linnworks/integration_tests/__init__.py new file mode 100644 index 000000000000..46b7376756ec --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/integration_tests/__init__.py @@ -0,0 +1,3 @@ +# +# Copyright (c) 2021 Airbyte, Inc., all rights reserved. +# diff --git a/airbyte-integrations/connectors/source-linnworks/integration_tests/abnormal_state.json b/airbyte-integrations/connectors/source-linnworks/integration_tests/abnormal_state.json new file mode 100644 index 000000000000..753bb769cc9b --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/integration_tests/abnormal_state.json @@ -0,0 +1,5 @@ +{ + "processed_orders": { + "dReceivedDate": "2050-01-01T00:00:00+00:00" + } +} diff --git a/airbyte-integrations/connectors/source-linnworks/integration_tests/acceptance.py b/airbyte-integrations/connectors/source-linnworks/integration_tests/acceptance.py new file mode 100644 index 000000000000..0347f2a0b143 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/integration_tests/acceptance.py @@ -0,0 +1,14 @@ +# +# Copyright (c) 2021 Airbyte, Inc., all rights reserved. +# + + +import pytest + +pytest_plugins = ("source_acceptance_test.plugin",) + + +@pytest.fixture(scope="session", autouse=True) +def connector_setup(): + """This fixture is a placeholder for external resources that acceptance test might require.""" + yield diff --git a/airbyte-integrations/connectors/source-linnworks/integration_tests/catalog.json b/airbyte-integrations/connectors/source-linnworks/integration_tests/catalog.json new file mode 100644 index 000000000000..9e63949c33fe --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/integration_tests/catalog.json @@ -0,0 +1,895 @@ +{ + "streams": [ + { + "name": "stock_locations", + "json_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "StockLocationId": { + "type": "string", + "description": "Location ID" + }, + "StockLocationIntId": { + "type": "integer", + "description": "Stock location integet id" + }, + "LocationName": { + "type": "string", + "description": "Location name" + }, + "IsFulfillmentCenter": { + "type": "boolean", + "description": "If location is a fulfilment center" + }, + "LocationTag": { + "type": "string", + "description": "Location tag" + }, + "BinRack": { + "type": "string", + "description": "Bin rack" + }, + "IsWarehouseManaged": { + "type": ["null", "boolean"], + "description": "If the location is warehosue managed." + }, + "location": { + "type": "object", + "additionalProperties": false, + "properties": { + "Address1": { + "type": "string", + "description": "1st line of address" + }, + "Address2": { + "type": "string", + "description": "2nd line of address" + }, + "City": { + "type": "string", + "description": "City" + }, + "County": { + "type": "string", + "description": "County / Region" + }, + "Country": { + "type": "string", + "description": "Country" + }, + "ZipCode": { + "type": "string", + "description": "Postal code" + }, + "IsNotTrackable": { + "type": "boolean", + "description": "Not trackable" + }, + "LocationTag": { + "type": "string", + "description": "Location tag" + }, + "CountInOrderUntilAcknowledgement": { + "type": "boolean", + "description": "Count in order" + }, + "FulfilmentCenterDeductStockWhenProcessed": { + "type": "boolean", + "description": "Fulfilment center and stock will be deducted when order processed" + }, + "IsWarehouseManaged": { + "type": "boolean", + "description": "Indicates if the location is warehosue managed." + }, + "StockLocationId": { + "type": "string", + "description": "Location ID" + }, + "LocationName": { + "type": "string", + "description": "Location name" + }, + "IsFulfillmentCenter": { + "type": "boolean", + "description": "If location is a fulfilment center" + }, + "StockLocationIntId": { + "type": "integer", + "description": "Stock location integer id." + } + } + } + } + }, + "supported_sync_modes": ["full_refresh"], + "source_defined_primary_key": [["StockLocationIntId"]] + }, + { + "name": "stock_items", + "json_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "Suppliers": { + "type": "array", + "description": "Suppliers", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "IsDefault": { + "type": "boolean", + "description": "If supplier information is default" + }, + "Supplier": { + "type": "string", + "description": "Supplier name" + }, + "SupplierID": { + "type": "string", + "description": "Supplier ID" + }, + "Code": { + "type": "string", + "description": "Supplier code" + }, + "SupplierBarcode": { + "type": "string", + "description": "Supplier barcode" + }, + "LeadTime": { + "type": "integer", + "description": "Supplier lead time" + }, + "PurchasePrice": { + "type": "number", + "description": "Supplier purchase price" + }, + "MinPrice": { + "type": "number", + "description": "Minimum price" + }, + "MaxPrice": { + "type": "number", + "description": "Maximum price" + }, + "AveragePrice": { + "type": "number", + "description": "Average price" + }, + "AverageLeadTime": { + "type": "number", + "description": "Average lead time" + }, + "SupplierMinOrderQty": { + "type": "integer", + "description": "Minimum order quantity from this supplier" + }, + "SupplierPackSize": { + "type": "integer", + "description": "Supplier pack size" + }, + "SupplierCurrency": { + "type": "string", + "description": "Supplier's default currency" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "StockLevels": { + "type": "array", + "description": "Stock Levels", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "Location": { + "type": "object", + "additionalProperties": false, + "description": "Location ID", + "properties": { + "StockLocationId": { + "type": "string", + "description": "Location ID" + }, + "StockLocationIntId": { + "type": "integer", + "description": "Stock location integet id" + }, + "LocationName": { + "type": "string", + "description": "Location name" + }, + "IsFulfillmentCenter": { + "type": "boolean", + "description": "If location is a fulfilment center" + }, + "LocationTag": { + "type": "string", + "description": "Location tag" + }, + "BinRack": { + "type": "string", + "description": "Bin rack" + }, + "IsWarehouseManaged": { + "type": ["null", "boolean"], + "description": "If the location is warehosue managed." + } + } + }, + "StockLevel": { + "type": "integer", + "description": "Stock level" + }, + "StockValue": { + "type": "number", + "description": "Stock value" + }, + "MinimumLevel": { + "type": "integer", + "description": "Minimum level" + }, + "InOrderBook": { + "type": "integer", + "description": "Currently in open orders" + }, + "Due": { + "type": "integer", + "description": "Due to come in purchase orders" + }, + "JIT": { + "type": "boolean", + "description": "Stock Item Just In Time (JIT) status" + }, + "InOrders": { + "type": "integer", + "description": "Currently in open orders" + }, + "Available": { + "type": "integer", + "description": "StockLevel - InOrders" + }, + "UnitCost": { + "type": "number", + "description": "if( Quantity == 0 ) dbo.StockItem.PurchasePrice Else CurrentStockValue / Quantity" + }, + "SKU": { + "type": "string", + "description": "Product SKU" + }, + "AutoAdjust": { + "type": "boolean", + "description": "If level is auto adjusted" + }, + "LastUpdateDate": { + "type": "string", + "format": "date-time", + "description": "Last time stock level was adjusted" + }, + "LastUpdateOperation": { + "type": "string", + "description": "Name of last update operation" + }, + "rowid": { + "type": "string", + "description": "dbo.StockLevel.rowid" + }, + "PendingUpdate": { + "type": "boolean", + "description": "dbo.StockLevel.PendingUpdate" + }, + "StockItemPurchasePrice": { + "type": "number", + "description": "Stock item purchase price. It's used to calculate UnitCost" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "ItemChannelDescriptions": { + "type": "array", + "description": "List of item descriptions", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": "string", + "description": "Record row ID (generate random GUID)" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "SubSource": { + "type": "string", + "description": "Channel subsource (e.g EBAY1)" + }, + "Description": { + "type": "string", + "description": "Product description" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "ItemExtendedProperties": { + "type": "array", + "description": "List of extended properties", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": "string", + "description": "Record row ID (generate random)" + }, + "fkStockItemId": { + "type": "string", + "description": "Stock Item ID" + }, + "ProperyName": { + "type": "string", + "description": "Property name" + }, + "PropertyValue": { + "type": "string", + "description": "Property value" + }, + "PropertyType": { + "type": "string", + "description": "Property type" + } + } + } + }, + "ItemChannelTitles": { + "type": "array", + "description": "List item titles", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": "string", + "description": "Record row id (generate random)" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "SubSource": { + "type": "string", + "description": "SubSource (EBAY1)" + }, + "Title": { + "type": "string", + "description": "Item title" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "ItemChannelPrices": { + "type": "array", + "description": "List of item prices", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "Rules": { + "type": "array", + "description": "Pricing rule", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": ["null", "integer"], + "description": "Record row ID (optional)" + }, + "fkStockPricingId": { + "type": "string", + "description": "Stock pricing ID" + }, + "Type": { + "type": "string", + "description": "Type" + }, + "LowerBound": { + "type": "integer", + "description": "Lower level" + }, + "Value": { + "type": "number", + "description": "Value/Price level" + } + } + } + }, + "pkRowId": { + "type": "string", + "description": "Record row ID (generate random)" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "SubSource": { + "type": "string", + "description": "SubSource (e.g. EBAY1)" + }, + "Price": { + "type": "number", + "description": "Channel price" + }, + "Tag": { + "type": "string", + "description": "Product price tag" + }, + "UpdateStatus": { + "type": "string" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "Images": { + "type": "array", + "description": "Image urls", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "Source": { + "type": "string", + "description": "URL to thumnail image" + }, + "FullSource": { + "type": "string", + "description": "Url to full size image" + }, + "CheckSumValue": { + "type": "string", + "description": "Image check sum" + }, + "pkRowId": { + "type": "string", + "description": "Unique id of image" + }, + "IsMain": { + "type": "boolean", + "description": "Is the image the main image" + }, + "SortOrder": { + "type": "integer", + "description": "Sort order for the image" + }, + "ChecksumValue": { + "type": "string", + "description": "Internal checksum value" + }, + "RawChecksum": { + "type": "string", + "description": "Raw file checksum (Used for UI to determine if the image file is the same before submitting for upload)" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "ItemNumber": { + "type": "string", + "description": "SKU" + }, + "ItemTitle": { + "type": "string", + "description": "Item title" + }, + "BarcodeNumber": { + "type": "string", + "description": "Barcode number" + }, + "MetaData": { + "type": "string", + "description": "Item description" + }, + "isBatchedStockType": { + "type": "boolean", + "description": "Returns true is the stock item is tracked by batch" + }, + "PurchasePrice": { + "type": "number", + "description": "Default item purchase price" + }, + "RetailPrice": { + "type": ["null", "number"], + "description": "Default item retail price" + }, + "TaxRate": { + "type": "number", + "description": "Default item tax rate. Set -1 to use country tax rate" + }, + "PostalServiceId": { + "type": "string", + "description": "Default postal service id" + }, + "PostalServiceName": { + "type": "string", + "description": "Default postal service name" + }, + "CategoryId": { + "type": "string", + "description": "Default category id" + }, + "CategoryName": { + "type": "string", + "description": "Default category name" + }, + "PackageGroupId": { + "type": "string", + "description": "Default package group id" + }, + "PackageGroupName": { + "type": "string", + "description": "Default package group name" + }, + "Height": { + "type": "number", + "description": "Item height" + }, + "Width": { + "type": "number", + "description": "Item width" + }, + "Depth": { + "type": "number", + "description": "Item depth" + }, + "Weight": { + "type": "number", + "description": "Item weight" + }, + "CreationDate": { + "type": ["null", "string"], + "format": "date-time", + "description": "Stock item creation date" + }, + "InventoryTrackingType": { + "type": "integer", + "description": "Stock item tracking type. 0 = none. 1 = Ordered by Sell by Date. 2 = Ordered by Priority Sequence" + }, + "BatchNumberScanRequired": { + "type": "boolean", + "description": "User must scan batch number when procesing orders" + }, + "SerialNumberScanRequired": { + "type": "boolean", + "description": "User must scan item serial number when processing ordesr" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + }, + "supported_sync_modes": ["full_refresh"], + "source_defined_primary_key": [["StockItemIntId"]] + }, + { + "name": "processed_orders", + "json_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "pkOrderID": { + "type": "string", + "description": "Order ID" + }, + "cShippingAddress": { + "type": "string", + "description": "Customer's shipping address" + }, + "dReceivedDate": { + "type": "string", + "format": "date-time", + "description": "Date when order was received on a channel" + }, + "dProcessedOn": { + "type": "string", + "format": "date-time", + "description": "Date when order was processed" + }, + "timeDiff": { + "type": "number", + "description": "Days elapsed between order received and order processed" + }, + "fPostageCost": { + "type": "number", + "description": "Order postage cost" + }, + "fTotalCharge": { + "type": "number", + "description": "Order total charge" + }, + "PostageCostExTax": { + "type": "number", + "description": "Postage cost excluding tax" + }, + "Subtotal": { + "type": "number", + "description": "Order subtotal" + }, + "fTax": { + "type": "number", + "description": "Order tax" + }, + "TotalDiscount": { + "type": "number", + "description": "Total discount" + }, + "ProfitMargin": { + "type": "number", + "description": "Profit margin" + }, + "CountryTaxRate": { + "type": "number", + "description": "Country specific tax rate" + }, + "nOrderId": { + "type": "integer", + "description": "Linnworks order ID" + }, + "nStatus": { + "type": "integer", + "description": "Order status" + }, + "cCurrency": { + "type": "string", + "description": "Order currency" + }, + "PostalTrackingNumber": { + "type": "string", + "description": "Postal tracking number" + }, + "cCountry": { + "type": "string", + "description": "Country" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "PostalServiceName": { + "type": "string", + "description": "Postal service name (e.g. Next day delivery)" + }, + "PostalServiceCode": { + "type": "string", + "description": "Postal service code" + }, + "Vendor": { + "type": "string", + "description": "Courier name (e.g. DPD)" + }, + "BillingEmailAddress": { + "type": "string" + }, + "ReferenceNum": { + "type": "string", + "description": "Order reference number" + }, + "SecondaryReference": { + "type": "string", + "description": "An additional reference number for the order" + }, + "ExternalReference": { + "type": "string", + "description": "This is an additional reference number from the sales channel, typically used by eBay" + }, + "Address1": { + "type": "string", + "description": "Order first line of address" + }, + "Address2": { + "type": "string", + "description": "Order second line of address" + }, + "Address3": { + "type": "string", + "description": "Order third line of address" + }, + "Town": { + "type": "string", + "description": "Town" + }, + "Region": { + "type": "string", + "description": "Region, county, area" + }, + "BuyerPhoneNumber": { + "type": "string", + "description": "Buyer phone number" + }, + "Company": { + "type": "string", + "description": "Company" + }, + "SubSource": { + "type": "string", + "description": "Order subsource (e.g. EBAY1)" + }, + "ChannelBuyerName": { + "type": "string", + "description": "Channel specific name used to identify the buyer, such as a username, normally used for eBay" + }, + "AccountName": { + "type": "string", + "description": "Customer channel account name" + }, + "cFullName": { + "type": "string", + "description": "Customer full name" + }, + "cEmailAddress": { + "type": "string", + "description": "Customer email address" + }, + "cPostCode": { + "type": "string", + "description": "Post Code" + }, + "dPaidOn": { + "type": "string", + "format": "date-time", + "description": "When order was marked as PAID" + }, + "dCancelledOn": { + "type": "string", + "format": "date-time", + "description": "When order was cancelled" + }, + "PackageCategory": { + "type": "string", + "description": "Package category" + }, + "PackageTitle": { + "type": "string", + "description": "Package name" + }, + "ItemWeight": { + "type": "number", + "description": "Items weight" + }, + "TotalWeight": { + "type": "number", + "description": "Total order weight" + }, + "FolderCollection": { + "type": "string", + "description": "Folder name of an order" + }, + "cBillingAddress": { + "type": "string", + "description": "Customer billing address" + }, + "BillingName": { + "type": "string", + "description": "Customer billing name" + }, + "BillingCompany": { + "type": "string", + "description": "Customer billing company" + }, + "BillingAddress1": { + "type": "string", + "description": "Billing address line one" + }, + "BillingAddress2": { + "type": "string", + "description": "Billing address line two" + }, + "BillingAddress3": { + "type": "string", + "description": "Billing address line three" + }, + "BillingTown": { + "type": "string", + "description": "Billing town" + }, + "BillingRegion": { + "type": "string", + "description": "Billing region, area, county" + }, + "BillingPostCode": { + "type": "string", + "description": "Billing postcode" + }, + "BillingCountryName": { + "type": "string", + "description": "Billing country" + }, + "BillingPhoneNumber": { + "type": "string", + "description": "Billing phone number" + }, + "HoldOrCancel": { + "type": "boolean", + "description": "If order on hold or cancelled." + }, + "IsResend": { + "type": "boolean", + "description": "If order was created from a resend" + }, + "IsExchange": { + "type": "boolean", + "description": "If order was created from an exchange" + }, + "TaxId": { + "type": "string", + "description": "Order tax id" + }, + "FulfilmentLocationName": { + "type": "string", + "description": "Order fulfilment location" + } + } + }, + "supported_sync_modes": ["full_refresh", "incremental"], + "source_defined_cursor": true, + "default_cursor_field": ["dReceivedDate"], + "source_defined_primary_key": [["nOrderId"]] + } + ] +} diff --git a/airbyte-integrations/connectors/source-linnworks/integration_tests/configured_catalog.json b/airbyte-integrations/connectors/source-linnworks/integration_tests/configured_catalog.json new file mode 100644 index 000000000000..8961de707cf6 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/integration_tests/configured_catalog.json @@ -0,0 +1,907 @@ +{ + "streams": [ + { + "stream": { + "name": "stock_locations", + "json_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "StockLocationId": { + "type": "string", + "description": "Location ID" + }, + "StockLocationIntId": { + "type": "integer", + "description": "Stock location integet id" + }, + "LocationName": { + "type": "string", + "description": "Location name" + }, + "IsFulfillmentCenter": { + "type": "boolean", + "description": "If location is a fulfilment center" + }, + "LocationTag": { + "type": "string", + "description": "Location tag" + }, + "BinRack": { + "type": "string", + "description": "Bin rack" + }, + "IsWarehouseManaged": { + "type": ["null", "boolean"], + "description": "If the location is warehosue managed." + }, + "location": { + "type": "object", + "additionalProperties": false, + "properties": { + "Address1": { + "type": "string", + "description": "1st line of address" + }, + "Address2": { + "type": "string", + "description": "2nd line of address" + }, + "City": { + "type": "string", + "description": "City" + }, + "County": { + "type": "string", + "description": "County / Region" + }, + "Country": { + "type": "string", + "description": "Country" + }, + "ZipCode": { + "type": "string", + "description": "Postal code" + }, + "IsNotTrackable": { + "type": "boolean", + "description": "Not trackable" + }, + "LocationTag": { + "type": "string", + "description": "Location tag" + }, + "CountInOrderUntilAcknowledgement": { + "type": "boolean", + "description": "Count in order" + }, + "FulfilmentCenterDeductStockWhenProcessed": { + "type": "boolean", + "description": "Fulfilment center and stock will be deducted when order processed" + }, + "IsWarehouseManaged": { + "type": "boolean", + "description": "Indicates if the location is warehosue managed." + }, + "StockLocationId": { + "type": "string", + "description": "Location ID" + }, + "LocationName": { + "type": "string", + "description": "Location name" + }, + "IsFulfillmentCenter": { + "type": "boolean", + "description": "If location is a fulfilment center" + }, + "StockLocationIntId": { + "type": "integer", + "description": "Stock location integer id." + } + } + } + } + }, + "supported_sync_modes": ["full_refresh"], + "source_defined_primary_key": [["StockLocationIntId"]] + }, + "sync_mode": "full_refresh", + "destination_sync_mode": "overwrite" + }, + { + "stream": { + "name": "stock_items", + "json_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "Suppliers": { + "type": "array", + "description": "Suppliers", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "IsDefault": { + "type": "boolean", + "description": "If supplier information is default" + }, + "Supplier": { + "type": "string", + "description": "Supplier name" + }, + "SupplierID": { + "type": "string", + "description": "Supplier ID" + }, + "Code": { + "type": "string", + "description": "Supplier code" + }, + "SupplierBarcode": { + "type": "string", + "description": "Supplier barcode" + }, + "LeadTime": { + "type": "integer", + "description": "Supplier lead time" + }, + "PurchasePrice": { + "type": "number", + "description": "Supplier purchase price" + }, + "MinPrice": { + "type": "number", + "description": "Minimum price" + }, + "MaxPrice": { + "type": "number", + "description": "Maximum price" + }, + "AveragePrice": { + "type": "number", + "description": "Average price" + }, + "AverageLeadTime": { + "type": "number", + "description": "Average lead time" + }, + "SupplierMinOrderQty": { + "type": "integer", + "description": "Minimum order quantity from this supplier" + }, + "SupplierPackSize": { + "type": "integer", + "description": "Supplier pack size" + }, + "SupplierCurrency": { + "type": "string", + "description": "Supplier's default currency" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "StockLevels": { + "type": "array", + "description": "Stock Levels", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "Location": { + "type": "object", + "additionalProperties": false, + "description": "Location ID", + "properties": { + "StockLocationId": { + "type": "string", + "description": "Location ID" + }, + "StockLocationIntId": { + "type": "integer", + "description": "Stock location integet id" + }, + "LocationName": { + "type": "string", + "description": "Location name" + }, + "IsFulfillmentCenter": { + "type": "boolean", + "description": "If location is a fulfilment center" + }, + "LocationTag": { + "type": "string", + "description": "Location tag" + }, + "BinRack": { + "type": "string", + "description": "Bin rack" + }, + "IsWarehouseManaged": { + "type": ["null", "boolean"], + "description": "If the location is warehosue managed." + } + } + }, + "StockLevel": { + "type": "integer", + "description": "Stock level" + }, + "StockValue": { + "type": "number", + "description": "Stock value" + }, + "MinimumLevel": { + "type": "integer", + "description": "Minimum level" + }, + "InOrderBook": { + "type": "integer", + "description": "Currently in open orders" + }, + "Due": { + "type": "integer", + "description": "Due to come in purchase orders" + }, + "JIT": { + "type": "boolean", + "description": "Stock Item Just In Time (JIT) status" + }, + "InOrders": { + "type": "integer", + "description": "Currently in open orders" + }, + "Available": { + "type": "integer", + "description": "StockLevel - InOrders" + }, + "UnitCost": { + "type": "number", + "description": "if( Quantity == 0 ) dbo.StockItem.PurchasePrice Else CurrentStockValue / Quantity" + }, + "SKU": { + "type": "string", + "description": "Product SKU" + }, + "AutoAdjust": { + "type": "boolean", + "description": "If level is auto adjusted" + }, + "LastUpdateDate": { + "type": "string", + "format": "date-time", + "description": "Last time stock level was adjusted" + }, + "LastUpdateOperation": { + "type": "string", + "description": "Name of last update operation" + }, + "rowid": { + "type": "string", + "description": "dbo.StockLevel.rowid" + }, + "PendingUpdate": { + "type": "boolean", + "description": "dbo.StockLevel.PendingUpdate" + }, + "StockItemPurchasePrice": { + "type": "number", + "description": "Stock item purchase price. It's used to calculate UnitCost" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "ItemChannelDescriptions": { + "type": "array", + "description": "List of item descriptions", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": "string", + "description": "Record row ID (generate random GUID)" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "SubSource": { + "type": "string", + "description": "Channel subsource (e.g EBAY1)" + }, + "Description": { + "type": "string", + "description": "Product description" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "ItemExtendedProperties": { + "type": "array", + "description": "List of extended properties", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": "string", + "description": "Record row ID (generate random)" + }, + "fkStockItemId": { + "type": "string", + "description": "Stock Item ID" + }, + "ProperyName": { + "type": "string", + "description": "Property name" + }, + "PropertyValue": { + "type": "string", + "description": "Property value" + }, + "PropertyType": { + "type": "string", + "description": "Property type" + } + } + } + }, + "ItemChannelTitles": { + "type": "array", + "description": "List item titles", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": "string", + "description": "Record row id (generate random)" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "SubSource": { + "type": "string", + "description": "SubSource (EBAY1)" + }, + "Title": { + "type": "string", + "description": "Item title" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "ItemChannelPrices": { + "type": "array", + "description": "List of item prices", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "Rules": { + "type": "array", + "description": "Pricing rule", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": ["null", "integer"], + "description": "Record row ID (optional)" + }, + "fkStockPricingId": { + "type": "string", + "description": "Stock pricing ID" + }, + "Type": { + "type": "string", + "description": "Type" + }, + "LowerBound": { + "type": "integer", + "description": "Lower level" + }, + "Value": { + "type": "number", + "description": "Value/Price level" + } + } + } + }, + "pkRowId": { + "type": "string", + "description": "Record row ID (generate random)" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "SubSource": { + "type": "string", + "description": "SubSource (e.g. EBAY1)" + }, + "Price": { + "type": "number", + "description": "Channel price" + }, + "Tag": { + "type": "string", + "description": "Product price tag" + }, + "UpdateStatus": { + "type": "string" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "Images": { + "type": "array", + "description": "Image urls", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "Source": { + "type": "string", + "description": "URL to thumnail image" + }, + "FullSource": { + "type": "string", + "description": "Url to full size image" + }, + "CheckSumValue": { + "type": "string", + "description": "Image check sum" + }, + "pkRowId": { + "type": "string", + "description": "Unique id of image" + }, + "IsMain": { + "type": "boolean", + "description": "Is the image the main image" + }, + "SortOrder": { + "type": "integer", + "description": "Sort order for the image" + }, + "ChecksumValue": { + "type": "string", + "description": "Internal checksum value" + }, + "RawChecksum": { + "type": "string", + "description": "Raw file checksum (Used for UI to determine if the image file is the same before submitting for upload)" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "ItemNumber": { + "type": "string", + "description": "SKU" + }, + "ItemTitle": { + "type": "string", + "description": "Item title" + }, + "BarcodeNumber": { + "type": "string", + "description": "Barcode number" + }, + "MetaData": { + "type": "string", + "description": "Item description" + }, + "isBatchedStockType": { + "type": "boolean", + "description": "Returns true is the stock item is tracked by batch" + }, + "PurchasePrice": { + "type": "number", + "description": "Default item purchase price" + }, + "RetailPrice": { + "type": ["null", "number"], + "description": "Default item retail price" + }, + "TaxRate": { + "type": "number", + "description": "Default item tax rate. Set -1 to use country tax rate" + }, + "PostalServiceId": { + "type": "string", + "description": "Default postal service id" + }, + "PostalServiceName": { + "type": "string", + "description": "Default postal service name" + }, + "CategoryId": { + "type": "string", + "description": "Default category id" + }, + "CategoryName": { + "type": "string", + "description": "Default category name" + }, + "PackageGroupId": { + "type": "string", + "description": "Default package group id" + }, + "PackageGroupName": { + "type": "string", + "description": "Default package group name" + }, + "Height": { + "type": "number", + "description": "Item height" + }, + "Width": { + "type": "number", + "description": "Item width" + }, + "Depth": { + "type": "number", + "description": "Item depth" + }, + "Weight": { + "type": "number", + "description": "Item weight" + }, + "CreationDate": { + "type": ["null", "string"], + "format": "date-time", + "description": "Stock item creation date" + }, + "InventoryTrackingType": { + "type": "integer", + "description": "Stock item tracking type. 0 = none. 1 = Ordered by Sell by Date. 2 = Ordered by Priority Sequence" + }, + "BatchNumberScanRequired": { + "type": "boolean", + "description": "User must scan batch number when procesing orders" + }, + "SerialNumberScanRequired": { + "type": "boolean", + "description": "User must scan item serial number when processing ordesr" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + }, + "supported_sync_modes": ["full_refresh"], + "source_defined_primary_key": [["StockItemIntId"]] + }, + "sync_mode": "full_refresh", + "destination_sync_mode": "overwrite" + }, + { + "stream": { + "name": "processed_orders", + "json_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "pkOrderID": { + "type": "string", + "description": "Order ID" + }, + "cShippingAddress": { + "type": "string", + "description": "Customer's shipping address" + }, + "dReceivedDate": { + "type": "string", + "format": "date-time", + "description": "Date when order was received on a channel" + }, + "dProcessedOn": { + "type": "string", + "format": "date-time", + "description": "Date when order was processed" + }, + "timeDiff": { + "type": "number", + "description": "Days elapsed between order received and order processed" + }, + "fPostageCost": { + "type": "number", + "description": "Order postage cost" + }, + "fTotalCharge": { + "type": "number", + "description": "Order total charge" + }, + "PostageCostExTax": { + "type": "number", + "description": "Postage cost excluding tax" + }, + "Subtotal": { + "type": "number", + "description": "Order subtotal" + }, + "fTax": { + "type": "number", + "description": "Order tax" + }, + "TotalDiscount": { + "type": "number", + "description": "Total discount" + }, + "ProfitMargin": { + "type": "number", + "description": "Profit margin" + }, + "CountryTaxRate": { + "type": "number", + "description": "Country specific tax rate" + }, + "nOrderId": { + "type": "integer", + "description": "Linnworks order ID" + }, + "nStatus": { + "type": "integer", + "description": "Order status" + }, + "cCurrency": { + "type": "string", + "description": "Order currency" + }, + "PostalTrackingNumber": { + "type": "string", + "description": "Postal tracking number" + }, + "cCountry": { + "type": "string", + "description": "Country" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "PostalServiceName": { + "type": "string", + "description": "Postal service name (e.g. Next day delivery)" + }, + "PostalServiceCode": { + "type": "string", + "description": "Postal service code" + }, + "Vendor": { + "type": "string", + "description": "Courier name (e.g. DPD)" + }, + "BillingEmailAddress": { + "type": "string" + }, + "ReferenceNum": { + "type": "string", + "description": "Order reference number" + }, + "SecondaryReference": { + "type": "string", + "description": "An additional reference number for the order" + }, + "ExternalReference": { + "type": "string", + "description": "This is an additional reference number from the sales channel, typically used by eBay" + }, + "Address1": { + "type": "string", + "description": "Order first line of address" + }, + "Address2": { + "type": "string", + "description": "Order second line of address" + }, + "Address3": { + "type": "string", + "description": "Order third line of address" + }, + "Town": { + "type": "string", + "description": "Town" + }, + "Region": { + "type": "string", + "description": "Region, county, area" + }, + "BuyerPhoneNumber": { + "type": "string", + "description": "Buyer phone number" + }, + "Company": { + "type": "string", + "description": "Company" + }, + "SubSource": { + "type": "string", + "description": "Order subsource (e.g. EBAY1)" + }, + "ChannelBuyerName": { + "type": "string", + "description": "Channel specific name used to identify the buyer, such as a username, normally used for eBay" + }, + "AccountName": { + "type": "string", + "description": "Customer channel account name" + }, + "cFullName": { + "type": "string", + "description": "Customer full name" + }, + "cEmailAddress": { + "type": "string", + "description": "Customer email address" + }, + "cPostCode": { + "type": "string", + "description": "Post Code" + }, + "dPaidOn": { + "type": "string", + "format": "date-time", + "description": "When order was marked as PAID" + }, + "dCancelledOn": { + "type": "string", + "format": "date-time", + "description": "When order was cancelled" + }, + "PackageCategory": { + "type": "string", + "description": "Package category" + }, + "PackageTitle": { + "type": "string", + "description": "Package name" + }, + "ItemWeight": { + "type": "number", + "description": "Items weight" + }, + "TotalWeight": { + "type": "number", + "description": "Total order weight" + }, + "FolderCollection": { + "type": "string", + "description": "Folder name of an order" + }, + "cBillingAddress": { + "type": "string", + "description": "Customer billing address" + }, + "BillingName": { + "type": "string", + "description": "Customer billing name" + }, + "BillingCompany": { + "type": "string", + "description": "Customer billing company" + }, + "BillingAddress1": { + "type": "string", + "description": "Billing address line one" + }, + "BillingAddress2": { + "type": "string", + "description": "Billing address line two" + }, + "BillingAddress3": { + "type": "string", + "description": "Billing address line three" + }, + "BillingTown": { + "type": "string", + "description": "Billing town" + }, + "BillingRegion": { + "type": "string", + "description": "Billing region, area, county" + }, + "BillingPostCode": { + "type": "string", + "description": "Billing postcode" + }, + "BillingCountryName": { + "type": "string", + "description": "Billing country" + }, + "BillingPhoneNumber": { + "type": "string", + "description": "Billing phone number" + }, + "HoldOrCancel": { + "type": "boolean", + "description": "If order on hold or cancelled." + }, + "IsResend": { + "type": "boolean", + "description": "If order was created from a resend" + }, + "IsExchange": { + "type": "boolean", + "description": "If order was created from an exchange" + }, + "TaxId": { + "type": "string", + "description": "Order tax id" + }, + "FulfilmentLocationName": { + "type": "string", + "description": "Order fulfilment location" + } + } + }, + "supported_sync_modes": ["full_refresh", "incremental"], + "source_defined_cursor": true, + "default_cursor_field": ["dReceivedDate"], + "source_defined_primary_key": [["nOrderId"]] + }, + "sync_mode": "incremental", + "destination_sync_mode": "append_dedup" + } + ] +} diff --git a/airbyte-integrations/connectors/source-linnworks/integration_tests/invalid_config.json b/airbyte-integrations/connectors/source-linnworks/integration_tests/invalid_config.json new file mode 100644 index 000000000000..ca70f85dbbdc --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/integration_tests/invalid_config.json @@ -0,0 +1,6 @@ +{ + "application_id": "invalid_id", + "application_secret": "invalid_secret", + "token": "invalid_token", + "start_date": "not-a-date" +} diff --git a/airbyte-integrations/connectors/source-linnworks/integration_tests/sample_config.json b/airbyte-integrations/connectors/source-linnworks/integration_tests/sample_config.json new file mode 100644 index 000000000000..648f8e297213 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/integration_tests/sample_config.json @@ -0,0 +1,6 @@ +{ + "application_id": "the_id", + "application_secret": "the_secret", + "token": "the_token", + "start_date": "2021-01-01T00:00:00+00:00" +} diff --git a/airbyte-integrations/connectors/source-linnworks/integration_tests/sample_state.json b/airbyte-integrations/connectors/source-linnworks/integration_tests/sample_state.json new file mode 100644 index 000000000000..fc0bacca9d82 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/integration_tests/sample_state.json @@ -0,0 +1,5 @@ +{ + "processed_orders": { + "dReceivedDate": "2021-01-01T00:00:00+00:00" + } +} diff --git a/airbyte-integrations/connectors/source-linnworks/main.py b/airbyte-integrations/connectors/source-linnworks/main.py new file mode 100644 index 000000000000..2bc633709f52 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/main.py @@ -0,0 +1,13 @@ +# +# Copyright (c) 2021 Airbyte, Inc., all rights reserved. +# + + +import sys + +from airbyte_cdk.entrypoint import launch +from source_linnworks import SourceLinnworks + +if __name__ == "__main__": + source = SourceLinnworks() + launch(source, sys.argv[1:]) diff --git a/airbyte-integrations/connectors/source-linnworks/requirements.txt b/airbyte-integrations/connectors/source-linnworks/requirements.txt new file mode 100644 index 000000000000..0411042aa091 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/requirements.txt @@ -0,0 +1,2 @@ +-e ../../bases/source-acceptance-test +-e . diff --git a/airbyte-integrations/connectors/source-linnworks/setup.py b/airbyte-integrations/connectors/source-linnworks/setup.py new file mode 100644 index 000000000000..697733d14421 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/setup.py @@ -0,0 +1,30 @@ +# +# Copyright (c) 2021 Airbyte, Inc., all rights reserved. +# + + +from setuptools import find_packages, setup + +MAIN_REQUIREMENTS = [ + "airbyte-cdk", +] + +TEST_REQUIREMENTS = [ + "pytest~=6.1", + "pytest-mock~=3.6.1", + "requests-mock~=1.9.3", + "source-acceptance-test", +] + +setup( + name="source_linnworks", + description="Source implementation for Linnworks.", + author="Labanoras Tech", + author_email="jv@labanoras.io", + packages=find_packages(), + install_requires=MAIN_REQUIREMENTS, + package_data={"": ["*.json", "schemas/*.json", "schemas/shared/*.json"]}, + extras_require={ + "tests": TEST_REQUIREMENTS, + }, +) diff --git a/airbyte-integrations/connectors/source-linnworks/source_linnworks/__init__.py b/airbyte-integrations/connectors/source-linnworks/source_linnworks/__init__.py new file mode 100644 index 000000000000..8c19a7e8afdf --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/source_linnworks/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (c) 2021 Airbyte, Inc., all rights reserved. +# + + +from .source import SourceLinnworks + +__all__ = ["SourceLinnworks"] diff --git a/airbyte-integrations/connectors/source-linnworks/source_linnworks/schemas/processed_orders.json b/airbyte-integrations/connectors/source-linnworks/source_linnworks/schemas/processed_orders.json new file mode 100644 index 000000000000..f6c5e796183f --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/source_linnworks/schemas/processed_orders.json @@ -0,0 +1,258 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "pkOrderID": { + "type": "string", + "description": "Order ID" + }, + "cShippingAddress": { + "type": "string", + "description": "Customer's shipping address" + }, + "dReceivedDate": { + "type": "string", + "format": "date-time", + "description": "Date when order was received on a channel" + }, + "dProcessedOn": { + "type": "string", + "format": "date-time", + "description": "Date when order was processed" + }, + "timeDiff": { + "type": "number", + "description": "Days elapsed between order received and order processed" + }, + "fPostageCost": { + "type": "number", + "description": "Order postage cost" + }, + "fTotalCharge": { + "type": "number", + "description": "Order total charge" + }, + "PostageCostExTax": { + "type": "number", + "description": "Postage cost excluding tax" + }, + "Subtotal": { + "type": "number", + "description": "Order subtotal" + }, + "fTax": { + "type": "number", + "description": "Order tax" + }, + "TotalDiscount": { + "type": "number", + "description": "Total discount" + }, + "ProfitMargin": { + "type": "number", + "description": "Profit margin" + }, + "CountryTaxRate": { + "type": "number", + "description": "Country specific tax rate" + }, + "nOrderId": { + "type": "integer", + "description": "Linnworks order ID" + }, + "nStatus": { + "type": "integer", + "description": "Order status" + }, + "cCurrency": { + "type": "string", + "description": "Order currency" + }, + "PostalTrackingNumber": { + "type": "string", + "description": "Postal tracking number" + }, + "cCountry": { + "type": "string", + "description": "Country" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "PostalServiceName": { + "type": "string", + "description": "Postal service name (e.g. Next day delivery)" + }, + "PostalServiceCode": { + "type": "string", + "description": "Postal service code" + }, + "Vendor": { + "type": "string", + "description": "Courier name (e.g. DPD)" + }, + "BillingEmailAddress": { + "type": "string" + }, + "ReferenceNum": { + "type": "string", + "description": "Order reference number" + }, + "SecondaryReference": { + "type": "string", + "description": "An additional reference number for the order" + }, + "ExternalReference": { + "type": "string", + "description": "This is an additional reference number from the sales channel, typically used by eBay" + }, + "Address1": { + "type": "string", + "description": "Order first line of address" + }, + "Address2": { + "type": "string", + "description": "Order second line of address" + }, + "Address3": { + "type": "string", + "description": "Order third line of address" + }, + "Town": { + "type": "string", + "description": "Town" + }, + "Region": { + "type": "string", + "description": "Region, county, area" + }, + "BuyerPhoneNumber": { + "type": "string", + "description": "Buyer phone number" + }, + "Company": { + "type": "string", + "description": "Company" + }, + "SubSource": { + "type": "string", + "description": "Order subsource (e.g. EBAY1)" + }, + "ChannelBuyerName": { + "type": "string", + "description": "Channel specific name used to identify the buyer, such as a username, normally used for eBay" + }, + "AccountName": { + "type": "string", + "description": "Customer channel account name" + }, + "cFullName": { + "type": "string", + "description": "Customer full name" + }, + "cEmailAddress": { + "type": "string", + "description": "Customer email address" + }, + "cPostCode": { + "type": "string", + "description": "Post Code" + }, + "dPaidOn": { + "type": "string", + "format": "date-time", + "description": "When order was marked as PAID" + }, + "dCancelledOn": { + "type": "string", + "format": "date-time", + "description": "When order was cancelled" + }, + "PackageCategory": { + "type": "string", + "description": "Package category" + }, + "PackageTitle": { + "type": "string", + "description": "Package name" + }, + "ItemWeight": { + "type": "number", + "description": "Items weight" + }, + "TotalWeight": { + "type": "number", + "description": "Total order weight" + }, + "FolderCollection": { + "type": "string", + "description": "Folder name of an order" + }, + "cBillingAddress": { + "type": "string", + "description": "Customer billing address" + }, + "BillingName": { + "type": "string", + "description": "Customer billing name" + }, + "BillingCompany": { + "type": "string", + "description": "Customer billing company" + }, + "BillingAddress1": { + "type": "string", + "description": "Billing address line one" + }, + "BillingAddress2": { + "type": "string", + "description": "Billing address line two" + }, + "BillingAddress3": { + "type": "string", + "description": "Billing address line three" + }, + "BillingTown": { + "type": "string", + "description": "Billing town" + }, + "BillingRegion": { + "type": "string", + "description": "Billing region, area, county" + }, + "BillingPostCode": { + "type": "string", + "description": "Billing postcode" + }, + "BillingCountryName": { + "type": "string", + "description": "Billing country" + }, + "BillingPhoneNumber": { + "type": "string", + "description": "Billing phone number" + }, + "HoldOrCancel": { + "type": "boolean", + "description": "If order on hold or cancelled." + }, + "IsResend": { + "type": "boolean", + "description": "If order was created from a resend" + }, + "IsExchange": { + "type": "boolean", + "description": "If order was created from an exchange" + }, + "TaxId": { + "type": "string", + "description": "Order tax id" + }, + "FulfilmentLocationName": { + "type": "string", + "description": "Order fulfilment location" + } + } +} diff --git a/airbyte-integrations/connectors/source-linnworks/source_linnworks/schemas/stock_items.json b/airbyte-integrations/connectors/source-linnworks/source_linnworks/schemas/stock_items.json new file mode 100644 index 000000000000..c878db0ee633 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/source_linnworks/schemas/stock_items.json @@ -0,0 +1,515 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "Suppliers": { + "type": "array", + "description": "Suppliers", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "IsDefault": { + "type": "boolean", + "description": "If supplier information is default" + }, + "Supplier": { + "type": "string", + "description": "Supplier name" + }, + "SupplierID": { + "type": "string", + "description": "Supplier ID" + }, + "Code": { + "type": "string", + "description": "Supplier code" + }, + "SupplierBarcode": { + "type": "string", + "description": "Supplier barcode" + }, + "LeadTime": { + "type": "integer", + "description": "Supplier lead time" + }, + "PurchasePrice": { + "type": "number", + "description": "Supplier purchase price" + }, + "MinPrice": { + "type": "number", + "description": "Minimum price" + }, + "MaxPrice": { + "type": "number", + "description": "Maximum price" + }, + "AveragePrice": { + "type": "number", + "description": "Average price" + }, + "AverageLeadTime": { + "type": "number", + "description": "Average lead time" + }, + "SupplierMinOrderQty": { + "type": "integer", + "description": "Minimum order quantity from this supplier" + }, + "SupplierPackSize": { + "type": "integer", + "description": "Supplier pack size" + }, + "SupplierCurrency": { + "type": "string", + "description": "Supplier default currency" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item integer Id" + } + } + } + }, + "StockLevels": { + "type": "array", + "description": "Stock Levels", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "Location": { + "type": "object", + "additionalProperties": false, + "description": "Location ID", + "properties": { + "StockLocationId": { + "type": "string", + "description": "Location ID" + }, + "StockLocationIntId": { + "type": "integer", + "description": "Stock location integer id" + }, + "LocationName": { + "type": "string", + "description": "Location name" + }, + "IsFulfillmentCenter": { + "type": "boolean", + "description": "If location is a fulfillment center" + }, + "LocationTag": { + "type": "string", + "description": "Location tag" + }, + "BinRack": { + "type": "string", + "description": "Bin rack" + }, + "IsWarehouseManaged": { + "type": ["null", "boolean"], + "description": "If the location is warehouse managed" + } + } + }, + "StockLevel": { + "type": "integer", + "description": "Stock level" + }, + "StockValue": { + "type": "number", + "description": "Stock value" + }, + "MinimumLevel": { + "type": "integer", + "description": "Minimum level" + }, + "InOrderBook": { + "type": "integer", + "description": "Currently in open orders" + }, + "Due": { + "type": "integer", + "description": "Due to come in purchase orders" + }, + "JIT": { + "type": "boolean", + "description": "Stock Item Just In Time (JIT) status" + }, + "InOrders": { + "type": "integer", + "description": "Currently in open orders" + }, + "Available": { + "type": "integer", + "description": "StockLevel - InOrders" + }, + "UnitCost": { + "type": "number", + "description": "if( Quantity == 0 ) dbo.StockItem.PurchasePrice Else CurrentStockValue / Quantity" + }, + "SKU": { + "type": "string", + "description": "Product SKU" + }, + "AutoAdjust": { + "type": "boolean", + "description": "If level is auto adjusted" + }, + "LastUpdateDate": { + "type": "string", + "format": "date-time", + "description": "Last time stock level was adjusted" + }, + "LastUpdateOperation": { + "type": "string", + "description": "Name of last update operation" + }, + "rowid": { + "type": "string", + "description": "dbo.StockLevel.rowid" + }, + "PendingUpdate": { + "type": "boolean", + "description": "dbo.StockLevel.PendingUpdate" + }, + "StockItemPurchasePrice": { + "type": "number", + "description": "Stock item purchase price. It's used to calculate UnitCost" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "ItemChannelDescriptions": { + "type": "array", + "description": "List of item descriptions", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": "string", + "description": "Record row ID (generate random GUID)" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "SubSource": { + "type": "string", + "description": "Channel subsource (e.g EBAY1)" + }, + "Description": { + "type": "string", + "description": "Product description" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "ItemExtendedProperties": { + "type": "array", + "description": "List of extended properties", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": "string", + "description": "Record row ID (generate random)" + }, + "fkStockItemId": { + "type": "string", + "description": "Stock Item ID" + }, + "ProperyName": { + "type": "string", + "description": "Property name" + }, + "PropertyValue": { + "type": "string", + "description": "Property value" + }, + "PropertyType": { + "type": "string", + "description": "Property type" + } + } + } + }, + "ItemChannelTitles": { + "type": "array", + "description": "List item titles", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": "string", + "description": "Record row id (generate random)" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "SubSource": { + "type": "string", + "description": "SubSource (EBAY1)" + }, + "Title": { + "type": "string", + "description": "Item title" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item integer Id" + } + } + } + }, + "ItemChannelPrices": { + "type": "array", + "description": "List of item prices", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "Rules": { + "type": "array", + "description": "Pricing rule", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "pkRowId": { + "type": ["null", "integer"], + "description": "Record row ID (optional)" + }, + "fkStockPricingId": { + "type": "string", + "description": "Stock pricing ID" + }, + "Type": { + "type": "string", + "description": "Type" + }, + "LowerBound": { + "type": "integer", + "description": "Lower level" + }, + "Value": { + "type": "number", + "description": "Value/Price level" + } + } + } + }, + "pkRowId": { + "type": "string", + "description": "Record row ID (generate random)" + }, + "Source": { + "type": "string", + "description": "ChannelName/Source (e.g. EBAY)" + }, + "SubSource": { + "type": "string", + "description": "SubSource (e.g. EBAY1)" + }, + "Price": { + "type": "number", + "description": "Channel price" + }, + "Tag": { + "type": "string", + "description": "Product price tag" + }, + "UpdateStatus": { + "type": "string" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "Images": { + "type": "array", + "description": "Image urls", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "Source": { + "type": "string", + "description": "URL to thumnail image" + }, + "FullSource": { + "type": "string", + "description": "Url to full size image" + }, + "CheckSumValue": { + "type": "string", + "description": "Image check sum" + }, + "pkRowId": { + "type": "string", + "description": "Unique id of image" + }, + "IsMain": { + "type": "boolean", + "description": "Is the image the main image" + }, + "SortOrder": { + "type": "integer", + "description": "Sort order for the image" + }, + "ChecksumValue": { + "type": "string", + "description": "Internal checksum value" + }, + "RawChecksum": { + "type": "string", + "description": "Raw file checksum (Used for UI to determine if the image file is the same before submitting for upload)" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } + } + }, + "ItemNumber": { + "type": "string", + "description": "SKU" + }, + "ItemTitle": { + "type": "string", + "description": "Item title" + }, + "BarcodeNumber": { + "type": "string", + "description": "Barcode number" + }, + "MetaData": { + "type": "string", + "description": "Item description" + }, + "isBatchedStockType": { + "type": "boolean", + "description": "Returns true is the stock item is tracked by batch" + }, + "PurchasePrice": { + "type": "number", + "description": "Default item purchase price" + }, + "RetailPrice": { + "type": ["null", "number"], + "description": "Default item retail price" + }, + "TaxRate": { + "type": "number", + "description": "Default item tax rate. Set -1 to use country tax rate" + }, + "PostalServiceId": { + "type": "string", + "description": "Default postal service id" + }, + "PostalServiceName": { + "type": "string", + "description": "Default postal service name" + }, + "CategoryId": { + "type": "string", + "description": "Default category id" + }, + "CategoryName": { + "type": "string", + "description": "Default category name" + }, + "PackageGroupId": { + "type": "string", + "description": "Default package group id" + }, + "PackageGroupName": { + "type": "string", + "description": "Default package group name" + }, + "Height": { + "type": "number", + "description": "Item height" + }, + "Width": { + "type": "number", + "description": "Item width" + }, + "Depth": { + "type": "number", + "description": "Item depth" + }, + "Weight": { + "type": "number", + "description": "Item weight" + }, + "CreationDate": { + "type": ["null", "string"], + "format": "date-time", + "description": "Stock item creation date" + }, + "InventoryTrackingType": { + "type": "integer", + "description": "Stock item tracking type. 0 = none. 1 = Ordered by Sell by Date. 2 = Ordered by Priority Sequence" + }, + "BatchNumberScanRequired": { + "type": "boolean", + "description": "User must scan batch number when procesing orders" + }, + "SerialNumberScanRequired": { + "type": "boolean", + "description": "User must scan item serial number when processing ordesr" + }, + "StockItemId": { + "type": "string", + "description": "Stock Item Id" + }, + "StockItemIntId": { + "type": "integer", + "description": "Stock Item interger Id" + } + } +} diff --git a/airbyte-integrations/connectors/source-linnworks/source_linnworks/schemas/stock_locations.json b/airbyte-integrations/connectors/source-linnworks/source_linnworks/schemas/stock_locations.json new file mode 100644 index 000000000000..dbf5c87f95c5 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/source_linnworks/schemas/stock_locations.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": false, + "properties": { + "StockLocationId": { + "type": "string", + "description": "Location ID" + }, + "StockLocationIntId": { + "type": "integer", + "description": "Stock location integer id" + }, + "LocationName": { + "type": "string", + "description": "Location name" + }, + "IsFulfillmentCenter": { + "type": "boolean", + "description": "If location is a fulfillment center" + }, + "LocationTag": { + "type": "string", + "description": "Location tag" + }, + "BinRack": { + "type": "string", + "description": "Bin rack" + }, + "IsWarehouseManaged": { + "type": ["null", "boolean"], + "description": "If the location is warehouse managed." + }, + "location": { + "type": "object", + "additionalProperties": false, + "properties": { + "Address1": { + "type": "string", + "description": "1st line of address" + }, + "Address2": { + "type": "string", + "description": "2nd line of address" + }, + "City": { + "type": "string", + "description": "City" + }, + "County": { + "type": "string", + "description": "County / Region" + }, + "Country": { + "type": "string", + "description": "Country" + }, + "ZipCode": { + "type": "string", + "description": "Postal code" + }, + "IsNotTrackable": { + "type": "boolean", + "description": "Is the location trackable" + }, + "LocationTag": { + "type": "string", + "description": "Location tag" + }, + "CountInOrderUntilAcknowledgement": { + "type": "boolean", + "description": "Count in order" + }, + "FulfilmentCenterDeductStockWhenProcessed": { + "type": "boolean", + "description": "Fulfilment center and stock will be deducted when order processed" + }, + "IsWarehouseManaged": { + "type": "boolean", + "description": "Indicates if the location is warehouse managed" + }, + "StockLocationId": { + "type": "string", + "description": "Location ID" + }, + "LocationName": { + "type": "string", + "description": "Location name" + }, + "IsFulfillmentCenter": { + "type": "boolean", + "description": "If location is a fulfillment center" + }, + "StockLocationIntId": { + "type": "integer", + "description": "Stock location integer id." + } + } + } + } +} diff --git a/airbyte-integrations/connectors/source-linnworks/source_linnworks/source.py b/airbyte-integrations/connectors/source-linnworks/source_linnworks/source.py new file mode 100644 index 000000000000..81469c31f194 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/source_linnworks/source.py @@ -0,0 +1,111 @@ +# +# Copyright (c) 2021 Airbyte, Inc., all rights reserved. +# + + +from typing import Any, List, Mapping, MutableMapping, Tuple + +import pendulum +import requests +from airbyte_cdk.sources import AbstractSource +from airbyte_cdk.sources.streams import Stream +from airbyte_cdk.sources.streams.http.requests_native_auth import Oauth2Authenticator + +from .streams import ProcessedOrders, StockItems, StockLocations + + +class LinnworksAuthenticator(Oauth2Authenticator): + def __init__( + self, + token_refresh_endpoint: str, + application_id: str, + application_secret: str, + token: str, + token_expiry_date: pendulum.datetime = None, + access_token_name: str = "Token", + expires_in_name: str = "TTL", + server_name: str = "Server", + ): + super().__init__( + token_refresh_endpoint, + application_id, + application_secret, + token, + scopes=None, + token_expiry_date=token_expiry_date, + access_token_name=access_token_name, + expires_in_name=expires_in_name, + ) + + self.application_id = application_id + self.application_secret = application_secret + self.token = token + self.server_name = server_name + + def get_auth_header(self) -> Mapping[str, Any]: + return {"Authorization": self.get_access_token()} + + def get_access_token(self): + if self.token_has_expired(): + t0 = pendulum.now() + token, expires_in, server = self.refresh_access_token() + self._access_token = token + self._token_expiry_date = t0.add(seconds=expires_in) + self._server = server + + return self._access_token + + def get_server(self): + if self.token_has_expired(): + self.get_access_token() + + return self._server + + def get_refresh_request_body(self) -> Mapping[str, Any]: + payload: MutableMapping[str, Any] = { + "applicationId": self.application_id, + "applicationSecret": self.application_secret, + "token": self.token, + } + + return payload + + def refresh_access_token(self) -> Tuple[str, int]: + try: + response = requests.request(method="POST", url=self.token_refresh_endpoint, data=self.get_refresh_request_body()) + response.raise_for_status() + response_json = response.json() + return response_json[self.access_token_name], response_json[self.expires_in_name], response_json[self.server_name] + except Exception as e: + try: + e = Exception(response.json()["Message"]) + except Exception: + # Unable to get an error message from the response body. + # Continue with the original error. + pass + raise Exception(f"Error while refreshing access token: {e}") from e + + +class SourceLinnworks(AbstractSource): + def _auth(self, config): + return LinnworksAuthenticator( + token_refresh_endpoint="https://api.linnworks.net/api/Auth/AuthorizeByApplication", + application_id=config["application_id"], + application_secret=config["application_secret"], + token=config["token"], + ) + + def check_connection(self, logger, config) -> Tuple[bool, any]: + try: + self._auth(config).get_auth_header() + except Exception as error: + return False, f"Unable to connect to Linnworks API with the provided credentials: {error}" + return True, None + + def streams(self, config: Mapping[str, Any]) -> List[Stream]: + auth = self._auth(config) + return [ + StockLocations(authenticator=auth), + StockItems(authenticator=auth), + ProcessedOrders(authenticator=auth, start_date=config["start_date"]), + ] diff --git a/airbyte-integrations/connectors/source-linnworks/source_linnworks/spec.json b/airbyte-integrations/connectors/source-linnworks/source_linnworks/spec.json new file mode 100644 index 000000000000..d1217998871c --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/source_linnworks/spec.json @@ -0,0 +1,30 @@ +{ + "documentationUrl": "https://docsurl.com", + "connectionSpecification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Linnworks Spec", + "type": "object", + "required": ["application_id", "application_secret", "token", "start_date"], + "additionalProperties": false, + "properties": { + "application_id": { + "title": "Application ID", + "type": "string" + }, + "application_secret": { + "title": "Application secret", + "type": "string", + "airbyte_secret": true + }, + "token": { + "title": "Token", + "type": "string" + }, + "start_date": { + "title": "Start date", + "type": "string", + "format": "date-time" + } + } + } +} diff --git a/airbyte-integrations/connectors/source-linnworks/source_linnworks/streams.py b/airbyte-integrations/connectors/source-linnworks/source_linnworks/streams.py new file mode 100644 index 000000000000..73a00bec7429 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/source_linnworks/streams.py @@ -0,0 +1,239 @@ +# +# Copyright (c) 2021 Airbyte, Inc., all rights reserved. +# + +import json +from abc import ABC, abstractmethod +from typing import Any, Iterable, List, Mapping, MutableMapping, Optional, Union +from urllib.parse import parse_qsl, urlparse + +import pendulum +import requests +from airbyte_cdk.models.airbyte_protocol import SyncMode +from airbyte_cdk.sources.streams.http import HttpStream +from airbyte_cdk.sources.streams.http.auth.core import HttpAuthenticator +from requests.auth import AuthBase + + +class LinnworksStream(HttpStream, ABC): + http_method = "POST" + + def __init__(self, authenticator: Union[AuthBase, HttpAuthenticator] = None, start_date: str = None): + super().__init__(authenticator=authenticator) + + self._authenticator = authenticator + self.start_date = start_date + + @property + def url_base(self) -> str: + return self.authenticator.get_server() + + def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, Any]]: + return None + + def request_params( + self, stream_state: Mapping[str, Any], stream_slice: Mapping[str, any] = None, next_page_token: Mapping[str, Any] = None + ) -> MutableMapping[str, Any]: + return {} + + def parse_response(self, response: requests.Response, **kwargs) -> Iterable[Mapping]: + json = response.json() + if not isinstance(json, list): + json = [json] + for record in json: + yield record + + def backoff_time(self, response: requests.Response) -> Optional[float]: + delay_time = response.headers.get("Retry-After") + if delay_time: + return int(delay_time) + + +class LinnworksGenericPagedResult(ABC): + # https://apps.linnworks.net/Api/Class/linnworks-spa-commondata-Generic-GenericPagedResult + @abstractmethod + def paged_result(self, response: requests.Response) -> Mapping[str, Any]: + pass + + def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, Any]]: + result = self.paged_result(response) + + if result["PageNumber"] < result["TotalPages"]: + return { + "PageNumber": result["PageNumber"] + 1, + "EntriesPerPage": result["EntriesPerPage"], + "TotalEntries": result["TotalEntries"], + "TotalPages": result["TotalPages"], + } + + +class Location(LinnworksStream): + # https://apps.linnworks.net/Api/Method/Locations-GetLocation + # Response: StockLocation https://apps.linnworks.net/Api/Class/linnworks-spa-commondata-Locations-ClassBase-StockLocation + # Allows 150 calls per minute + primary_key = "StockLocationIntId" + + def path( + self, stream_state: Mapping[str, Any] = None, stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None + ) -> str: + return "/api/Locations/GetLocation" + + def request_params( + self, stream_state: Mapping[str, Any], stream_slice: Mapping[str, any] = None, next_page_token: Mapping[str, Any] = None + ) -> MutableMapping[str, Any]: + return {"pkStockLocationId ": stream_state["pkStockLocationId"]} + + +class StockLocations(LinnworksStream): + # https://apps.linnworks.net/Api/Method/Inventory-GetStockLocations + # Response: List https://apps.linnworks.net/Api/Class/linnworks-spa-commondata-Inventory-ClassBase-StockLocation + # Allows 150 calls per minute + primary_key = "StockLocationIntId" + + def path( + self, stream_state: Mapping[str, Any] = None, stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None + ) -> str: + return "/api/Inventory/GetStockLocations" + + def read_records( + self, + sync_mode: SyncMode, + cursor_field: List[str] = None, + stream_slice: Mapping[str, Any] = None, + stream_state: Mapping[str, Any] = None, + ) -> Iterable[Mapping[str, Any]]: + records = super().read_records(sync_mode, cursor_field, stream_slice, stream_state) + + for record in records: + location = Location(authenticator=self.authenticator) + stock_location_records = location.read_records( + sync_mode, cursor_field, stream_slice, {"pkStockLocationId": record["StockLocationId"]} + ) + record["location"] = next(stock_location_records) + yield record + + +class StockItems(LinnworksStream): + # https://apps.linnworks.net//Api/Method/Stock-GetStockItemsFull + # Response: List https://apps.linnworks.net/Api/Class/linnworks-spa-commondata-Inventory-ClassBase-StockItemFull + # Allows 250 calls per minute + primary_key = "StockItemIntId" + page_size = 200 + + raise_on_http_errors = False + + def path( + self, stream_state: Mapping[str, Any] = None, stream_slice: Mapping[str, Any] = None, next_page_token: Mapping[str, Any] = None + ) -> str: + return "/api/Stock/GetStockItemsFull" + + def next_page_token(self, response: requests.Response) -> Optional[Mapping[str, Any]]: + url = urlparse(response.request.url) + qs = dict(parse_qsl(url.query)) + + page_size = int(qs.get("entriesPerPage", self.page_size)) + page_number = int(qs.get("pageNumber", 0)) + + data = response.json() + + if response.status_code == requests.codes.ok and len(data) == page_size: + return { + "entriesPerPage": page_size, + "pageNumber": page_number + 1, + } + + def parse_response(self, response: requests.Response, **kwargs) -> Iterable[Mapping]: + if response.status_code == requests.codes.bad_request: + return None + response.raise_for_status() + yield from super().parse_response(response, **kwargs) + + def request_params( + self, stream_state: Mapping[str, Any], stream_slice: Mapping[str, any] = None, next_page_token: Mapping[str, Any] = None + ) -> MutableMapping[str, Any]: + params = { + "entriesPerPage": self.page_size, + "pageNumber": 1, + "loadCompositeParents": "true", + "loadVariationParents": "true", + "dataRequirements": "[0,1,2,3,4,5,6,7,8]", + "searchTypes": "[0,1,2]", + } + + if next_page_token: + params.update(next_page_token) + + return params + + +class IncrementalLinnworksStream(LinnworksStream, ABC): + @property + def cursor_field(self) -> str: + return True + + def get_updated_state(self, current_stream_state: MutableMapping[str, Any], latest_record: Mapping[str, Any]) -> Mapping[str, Any]: + current = current_stream_state.get(self.cursor_field, "") + latest = latest_record.get(self.cursor_field, "") + + return { + self.cursor_field: max(latest, current), + } + + +class ProcessedOrders(LinnworksGenericPagedResult, IncrementalLinnworksStream): + # https://apps.linnworks.net/Api/Method/ProcessedOrders-SearchProcessedOrders + # Response: SearchProcessedOrdersResponse https://apps.linnworks.net/Api/Class/API_Linnworks-Controllers-ProcessedOrders-Responses-SearchProcessedOrdersResponse + # Allows 150 calls per minute + primary_key = "nOrderId" + cursor_field = "dReceivedDate" + page_size = 500 + + def path(self, **kwargs) -> str: + return "/api/ProcessedOrders/SearchProcessedOrders" + + def stream_slices(self, stream_state: Mapping[str, Any] = None, **kwargs) -> Iterable[Optional[Mapping[str, any]]]: + if not stream_state: + stream_state = {} + + from_date = pendulum.parse(stream_state.get(self.cursor_field, self.start_date)) + end_date = max(from_date, pendulum.tomorrow("UTC")) + + date_diff = end_date - from_date + if date_diff.years > 0: + interval = pendulum.duration(months=1) + elif date_diff.months > 0: + interval = pendulum.duration(weeks=1) + elif date_diff.weeks > 0: + interval = pendulum.duration(days=1) + else: + interval = pendulum.duration(hours=1) + + while True: + to_date = min(from_date + interval, end_date) + yield {"FromDate": from_date.isoformat(), "ToDate": to_date.isoformat()} + from_date = to_date + if from_date >= end_date: + break + + def request_body_data( + self, stream_state: Mapping[str, Any], stream_slice: Mapping[str, any] = None, next_page_token: Mapping[str, Any] = None + ) -> MutableMapping[str, Any]: + request = { + "DateField": "received", + "FromDate": stream_slice["FromDate"], + "ToDate": stream_slice["ToDate"], + "PageNumber": 1 if not next_page_token else next_page_token["PageNumber"], + "ResultsPerPage": self.page_size, + "SearchSorting": {"SortField": "dReceivedDate", "SortDirection": "ASC"}, + } + + return { + "request": json.dumps(request, separators=(",", ":")), + } + + def paged_result(self, response: requests.Response) -> Mapping[str, Any]: + return response.json()["ProcessedOrders"] + + def parse_response(self, response: requests.Response, **kwargs) -> Iterable[Mapping]: + for record in self.paged_result(response)["Data"]: + yield record diff --git a/airbyte-integrations/connectors/source-linnworks/unit_tests/__init__.py b/airbyte-integrations/connectors/source-linnworks/unit_tests/__init__.py new file mode 100644 index 000000000000..46b7376756ec --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/unit_tests/__init__.py @@ -0,0 +1,3 @@ +# +# Copyright (c) 2021 Airbyte, Inc., all rights reserved. +# diff --git a/airbyte-integrations/connectors/source-linnworks/unit_tests/test_incremental_streams.py b/airbyte-integrations/connectors/source-linnworks/unit_tests/test_incremental_streams.py new file mode 100644 index 000000000000..1c64338ec458 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/unit_tests/test_incremental_streams.py @@ -0,0 +1,193 @@ +# +# Copyright (c) 2021 Airbyte, Inc., all rights reserved. +# + + +import json + +import pendulum +import pytest +import requests +from source_linnworks.streams import IncrementalLinnworksStream, ProcessedOrders + + +@pytest.fixture +def patch_incremental_base_class(mocker): + mocker.patch.object(IncrementalLinnworksStream, "path", "v0/example_endpoint") + mocker.patch.object(IncrementalLinnworksStream, "primary_key", "test_primary_key") + mocker.patch.object(IncrementalLinnworksStream, "cursor_field", "test_cursor_field") + mocker.patch.object(IncrementalLinnworksStream, "__abstractmethods__", set()) + + +def test_cursor_field(patch_incremental_base_class): + stream = IncrementalLinnworksStream() + expected_cursor_field = "test_cursor_field" + assert stream.cursor_field == expected_cursor_field + + +@pytest.mark.parametrize( + ("inputs", "expected_state"), + [ + ( + { + "current_stream_state": { + "test_cursor_field": "2021-01-01T01:02:34+01:56", + }, + "latest_record": {}, + }, + {"test_cursor_field": "2021-01-01T01:02:34+01:56"}, + ), + ( + { + "current_stream_state": {}, + "latest_record": { + "test_cursor_field": "2021-01-01T01:02:34+01:56", + }, + }, + {"test_cursor_field": "2021-01-01T01:02:34+01:56"}, + ), + ( + { + "current_stream_state": { + "test_cursor_field": "2021-01-01T01:02:34+01:56", + }, + "latest_record": { + "test_cursor_field": "2021-01-01T01:02:34+01:57", + }, + }, + {"test_cursor_field": "2021-01-01T01:02:34+01:57"}, + ), + ], +) +def test_get_updated_state(patch_incremental_base_class, inputs, expected_state): + stream = IncrementalLinnworksStream() + assert stream.get_updated_state(**inputs) == expected_state + + +def test_supports_incremental(patch_incremental_base_class, mocker): + mocker.patch.object(IncrementalLinnworksStream, "cursor_field", "dummy_field") + stream = IncrementalLinnworksStream() + assert stream.supports_incremental + + +def test_source_defined_cursor(patch_incremental_base_class): + stream = IncrementalLinnworksStream() + assert stream.source_defined_cursor + + +def test_stream_checkpoint_interval(patch_incremental_base_class): + stream = IncrementalLinnworksStream() + expected_checkpoint_interval = None + assert stream.state_checkpoint_interval == expected_checkpoint_interval + + +def date(*args): + return pendulum.datetime(*args).isoformat() + + +@pytest.mark.parametrize( + ("now", "stream_state", "slice_count", "expected_from_date", "expected_to_date"), + [ + (None, None, 24, date(2050, 1, 1), date(2050, 1, 2)), + (date(2050, 1, 2), None, 48, date(2050, 1, 1), date(2050, 1, 3)), + (None, {"dReceivedDate": date(2050, 1, 4)}, 1, date(2050, 1, 4), date(2050, 1, 4)), + ( + date(2050, 1, 5), + {"dReceivedDate": date(2050, 1, 4)}, + 48, + date(2050, 1, 4), + date(2050, 1, 6), + ), + ( + # Yearly + date(2052, 1, 1), + {"dReceivedDate": date(2050, 1, 1)}, + 25, + date(2050, 1, 1), + date(2052, 1, 2), + ), + ( + # Monthly + date(2050, 4, 1), + {"dReceivedDate": date(2050, 1, 1)}, + 13, + date(2050, 1, 1), + date(2050, 4, 2), + ), + ( + # Weekly + date(2050, 1, 31), + {"dReceivedDate": date(2050, 1, 1)}, + 5, + date(2050, 1, 1), + date(2050, 2, 1), + ), + ( + # Daily + date(2050, 1, 1, 23, 59, 59), + {"dReceivedDate": date(2050, 1, 1)}, + 24, + date(2050, 1, 1), + date(2050, 1, 2), + ), + ], +) +def test_processed_orders_stream_slices(patch_incremental_base_class, now, stream_state, slice_count, expected_from_date, expected_to_date): + start_date = date(2050, 1, 1) + pendulum.set_test_now(pendulum.parse(now if now else start_date)) + + stream = ProcessedOrders(start_date=start_date) + stream_slices = list(stream.stream_slices(stream_state)) + + assert len(stream_slices) == slice_count + assert stream_slices[0]["FromDate"] == expected_from_date + assert stream_slices[-1]["ToDate"] == expected_to_date + + +@pytest.mark.parametrize( + ("page_number"), + [ + (None), + (42), + ], +) +def test_processed_orders_request_body_data(patch_incremental_base_class, page_number): + stream_slice = {"FromDate": "FromDateValue", "ToDate": "ToDateValue"} + next_page_token = {"PageNumber": page_number} + + stream = ProcessedOrders() + request_body_data = stream.request_body_data(None, stream_slice, next_page_token) + data = json.loads(request_body_data["request"]) + + assert stream_slice.items() < data.items() + assert next_page_token.items() < data.items() + + +def test_processed_orders_paged_result(patch_incremental_base_class, requests_mock): + requests_mock.get("https://dummy", json={"ProcessedOrders": "the_orders"}) + good_response = requests.get("https://dummy") + + requests_mock.get("https://dummy", json={"OtherData": "the_data"}) + bad_response = requests.get("https://dummy") + + stream = ProcessedOrders() + result = stream.paged_result(good_response) + assert result == "the_orders" + + with pytest.raises(KeyError, match="'ProcessedOrders'"): + stream.paged_result(bad_response) + + +def test_processed_orders_parse_response(patch_incremental_base_class, requests_mock): + requests_mock.get("https://dummy", json={"ProcessedOrders": {"Data": [1, 2, 3]}}) + good_response = requests.get("https://dummy") + + requests_mock.get("https://dummy", json={"ProcessedOrders": {"OtherData": [1, 2, 3]}}) + bad_response = requests.get("https://dummy") + + stream = ProcessedOrders() + result = stream.parse_response(good_response) + assert list(result) == [1, 2, 3] + + with pytest.raises(KeyError, match="'Data'"): + list(stream.parse_response(bad_response)) diff --git a/airbyte-integrations/connectors/source-linnworks/unit_tests/test_source.py b/airbyte-integrations/connectors/source-linnworks/unit_tests/test_source.py new file mode 100644 index 000000000000..dec58d6d1002 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/unit_tests/test_source.py @@ -0,0 +1,107 @@ +# +# Copyright (c) 2021 Airbyte, Inc., all rights reserved. +# + +from unittest.mock import MagicMock + +import pytest +from source_linnworks.source import LinnworksAuthenticator, SourceLinnworks + + +@pytest.fixture +def config(): + return {"config": {"application_id": "xxx", "application_secret": "yyy", "token": "zzz", "start_date": "2021-11-01"}} + + +@pytest.mark.parametrize( + ("status_code", "is_json", "response", "expected"), + [ + ( + 200, + True, + { + "Token": "00000000-0000-0000-0000-000000000000", + "Server": "https://xx-ext.linnworks.net", + "TTL": 1234, + }, + (True, None), + ), + ( + 400, + True, + { + "Code": None, + "Message": "Invalid application id or application secret", + }, + ( + False, + "Unable to connect to Linnworks API with the provided credentials: Error while refreshing access token: Invalid application id or application secret", + ), + ), + ( + 400, + False, + "invalid_json", + ( + False, + "Unable to connect to Linnworks API with the provided credentials: Error while refreshing access token: 400 Client Error: None for url: https://api.linnworks.net/api/Auth/AuthorizeByApplication", + ), + ), + ], +) +def test_check_connection(mocker, config, requests_mock, status_code, is_json, response, expected): + source = SourceLinnworks() + logger_mock = MagicMock() + + kwargs = {"status_code": status_code} + if is_json: + kwargs["json"] = response + else: + kwargs["text"] = response + + requests_mock.post("https://api.linnworks.net/api/Auth/AuthorizeByApplication", **kwargs) + assert source.check_connection(logger_mock, **config) == expected + + +def test_authenticator_success(mocker, config, requests_mock): + config = config["config"] + authenticator = LinnworksAuthenticator( + token_refresh_endpoint="http://dummy", + application_id=config["application_id"], + application_secret=config["application_secret"], + token=config["token"], + ) + response = { + "Token": "00000000-0000-0000-0000-000000000000", + "Server": "http://xx-ext.dummy", + "TTL": 1234, + } + requests_mock.post("http://dummy", json=response) + + assert authenticator.get_server() == response["Server"] + + +def test_authenticator_error(mocker, config, requests_mock): + config = config["config"] + authenticator = LinnworksAuthenticator( + token_refresh_endpoint="http://dummy", + application_id=config["application_id"], + application_secret=config["application_secret"], + token=config["token"], + ) + response = { + "Code": None, + "Message": "Invalid application id or application secret", + } + requests_mock.post("http://dummy", json=response) + + with pytest.raises(Exception, match="Error while refreshing access token: Invalid application id or application secret"): + authenticator.get_server() + + +def test_streams(mocker): + source = SourceLinnworks() + config_mock = MagicMock() + streams = source.streams(config_mock) + expected_streams_number = 3 + assert len(streams) == expected_streams_number diff --git a/airbyte-integrations/connectors/source-linnworks/unit_tests/test_streams.py b/airbyte-integrations/connectors/source-linnworks/unit_tests/test_streams.py new file mode 100644 index 000000000000..0406704233b7 --- /dev/null +++ b/airbyte-integrations/connectors/source-linnworks/unit_tests/test_streams.py @@ -0,0 +1,143 @@ +# +# Copyright (c) 2021 Airbyte, Inc., all rights reserved. +# + +from unittest.mock import MagicMock + +import pytest +import requests +from airbyte_cdk.models.airbyte_protocol import SyncMode +from source_linnworks.streams import LinnworksStream, Location, StockItems, StockLocations + + +@pytest.fixture +def patch_base_class(mocker): + mocker.patch.object(LinnworksStream, "path", "v0/example_endpoint") + mocker.patch.object(LinnworksStream, "primary_key", "test_primary_key") + mocker.patch.object(LinnworksStream, "__abstractmethods__", set()) + + +def test_request_params(patch_base_class): + stream = LinnworksStream() + inputs = {"stream_slice": None, "stream_state": None, "next_page_token": None} + expected_params = {} + assert stream.request_params(**inputs) == expected_params + + +def test_next_page_token(patch_base_class): + stream = LinnworksStream() + inputs = {"response": MagicMock()} + expected_token = None + assert stream.next_page_token(**inputs) == expected_token + + +def test_parse_response(patch_base_class, requests_mock): + stream = LinnworksStream() + requests_mock.get( + "https://dummy", + json={ + "Foo": "foo", + "Bar": { + "Baz": "baz", + }, + }, + ) + resp = requests.get("https://dummy") + inputs = {"response": resp} + expected_parsed_object = {"Bar": {"Baz": "baz"}, "Foo": "foo"} + assert next(stream.parse_response(**inputs)) == expected_parsed_object + + +def test_http_method(patch_base_class): + stream = LinnworksStream() + expected_method = "POST" + assert stream.http_method == expected_method + + +@pytest.mark.parametrize( + ("header_name", "header_value", "expected"), + [ + ("Retry-After", "123", 123), + ("Retry-After", "-123", -123), + ], +) +def test_backoff_time(patch_base_class, requests_mock, header_name, header_value, expected): + stream = LinnworksStream() + requests_mock.get("https://dummy", headers={header_name: header_value}, status_code=429) + result = stream.backoff_time(requests.get("https://dummy")) + assert result == expected + + +def test_stock_locations_read_records(mocker): + fake_stock_locations = [ + {"StockLocationId": 1}, + {"StockLocationId": 2}, + ] + + mocker.patch.object(LinnworksStream, "read_records", lambda *args: iter(fake_stock_locations)) + mocker.patch.object(Location, "read_records", lambda *args: iter([{"FakeLocationFor": args[-1]}])) + + source = StockLocations() + records = source.read_records(SyncMode.full_refresh) + + assert list(records) == [ + {"StockLocationId": 1, "location": {"FakeLocationFor": {"pkStockLocationId": 1}}}, + {"StockLocationId": 2, "location": {"FakeLocationFor": {"pkStockLocationId": 2}}}, + ] + + +@pytest.mark.parametrize( + ("query", "item_count", "expected"), + [ + ("", 0, None), + ("?entriesPerPage=100&pageNumber=1", 100, {"entriesPerPage": 100, "pageNumber": 2}), + ("?entriesPerPage=200&pageNumber=2", 100, None), + ], +) +def test_stock_items_next_page_token(mocker, requests_mock, query, item_count, expected): + url = f"http://dummy{query}" + requests_mock.get(url, json=[None] * item_count) + response = requests.get(url) + + source = StockItems() + next_page_token = source.next_page_token(response) + + assert next_page_token == expected + + +@pytest.mark.parametrize( + ("status_code", "expected"), + [ + (200, ["the_response"]), + (400, []), + (500, []), + ], +) +def test_stock_items_parse_response(mocker, requests_mock, status_code, expected): + requests_mock.get("https://dummy", json="the_response", status_code=status_code) + response = requests.get("https://dummy") + + source = StockItems() + parsed_response = source.parse_response(response) + + if status_code not in [200, 400]: + with pytest.raises(requests.exceptions.HTTPError): + list(parsed_response) + else: + assert list(parsed_response) == expected + + +@pytest.mark.parametrize( + ("next_page_token", "expected"), + [ + (None, False), + ({"NextPageTokenKey": "NextPageTokenValue"}, True), + ], +) +def test_stock_items_request_params(mocker, requests_mock, next_page_token, expected): + source = StockItems() + params = source.request_params(None, None, next_page_token) + + assert ("NextPageTokenKey" in params) == expected + if next_page_token: + assert next_page_token.items() <= params.items() diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 35e10906ec6f..19b4f5489161 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -76,6 +76,7 @@ * [Kustomer](integrations/sources/kustomer.md) * [Lemlist](integrations/sources/lemlist.md) * [LinkedIn Ads](integrations/sources/linkedin-ads.md) + * [Linnworks](integrations/sources/linnworks.md) * [Lever Hiring](integrations/sources/lever-hiring.md) * [Looker](integrations/sources/looker.md) * [Magento](integrations/sources/magento.md) @@ -127,7 +128,7 @@ * [Sugar CRM](integrations/sources/sugar-crm.md) * [SurveyMonkey](integrations/sources/surveymonkey.md) * [Tempo](integrations/sources/tempo.md) - * [TikTok Marketing](integrations/sources/tiktok-marketing.md) + * [TikTok Marketing](integrations/sources/tiktok-marketing.md) * [Trello](integrations/sources/trello.md) * [Twilio](integrations/sources/twilio.md) * [Typeform](integrations/sources/typeform.md) diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 501ec8564546..4a0782d6b0b4 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -59,6 +59,7 @@ Airbyte uses a grading system for connectors to help users understand what to ex | [Klaviyo](sources/kustomer.md) | Alpha | | [Lemlist](sources/lemlist.md) | Alpha | | [LinkedIn Ads](sources/linkedin-ads.md) | Beta | +| [Linnworks](sources/linnworks.md) | Alpha | | [Kustomer](sources/kustomer.md) | Alpha | | [Lever Hiring](sources/lever-hiring.md) | Beta | | [Looker](sources/looker.md) | Beta | diff --git a/docs/integrations/sources/linnworks.md b/docs/integrations/sources/linnworks.md new file mode 100644 index 000000000000..3e0e3f7e39d8 --- /dev/null +++ b/docs/integrations/sources/linnworks.md @@ -0,0 +1,56 @@ +# Linnworks + +## Sync overview + +Linnworks source supports both Full Refresh and Incremental syncs. You can choose if this connector will copy only the new or updated data, or all rows in the tables and columns you set up for replication, every time a sync is run. + +This Source Connector is based on a [Airbyte CDK](https://docs.airbyte.io/connector-development/cdk-python). Airbyte uses [Linnworks API](https://apps.linnworks.net/Api) to fetch data from Linnworks. + +### Output schema + +This Source is capable of syncing the following data as streams: + +* [StockLocations](https://apps.linnworks.net/Api/Method/Inventory-GetStockLocations) +* [StockItems](https://apps.linnworks.net//Api/Method/Stock-GetStockItemsFull) +* [ProcessedOrders](https://apps.linnworks.net/Api/Method/ProcessedOrders-SearchProcessedOrders) + +### Data type mapping + +| Integration Type | Airbyte Type | Notes | +| :--- | :--- | :--- | +| `number` | `number` | float number | +| `integer` | `integer` | whole number | +| `date` | `string` | FORMAT YYYY-MM-DD | +| `datetime` | `string` | FORMAT YYYY-MM-DDThh:mm:ss | +| `array` | `array` | | +| `boolean` | `boolean` | True/False | +| `string` | `string` | | + +### Features + +| Feature | Supported?\(Yes/No\) | Notes | +| :--- | :--- | :--- | +| Full Refresh Overwrite Sync | Yes | | +| Full Refresh Append Sync | Yes | | +| Incremental - Append Sync | Yes | | +| Incremental - Append + Deduplication Sync | Yes | | +| Namespaces | No | | + +### Performance considerations + +Rate limit varies across Linnworks API endpoint. See the endpoint documentation to learn more. Rate limited requests will receive a 429 response. The Linnworks connector should not run into Linnworks API limitations under normal usage. + +## Getting started + +### Authentication + +Linnworks platform has two portals: seller and developer. First, to create API credentials, log in to the [developer portal](https://developer.linnworks.com) and create an application of type `System Integration`. Then click on provided Installation URL and proceed with an installation wizard. The wizard will show a token that you will need for authentication. The installed application will be present on your account on [seller portal](https://login.linnworks.net/). + +Authentication credentials can be obtained on developer portal section Applications -> _Your application name_ -> Edit -> General. And the token, if you missed it during the install, can be obtained anytime under the section Applications -> _Your application name_ -> Installs. + +## Changelog + +| Version | Date | Pull Request | Subject | +| :--- | :--- | :--- | :--- | +| 0.1.0 | 2021-11-09 | [7588](https://github.com/airbytehq/airbyte/pull/7588) | New Source: Linnworks | + diff --git a/tools/bin/ci_credentials.sh b/tools/bin/ci_credentials.sh index 01822588c87c..bf533229a9a0 100755 --- a/tools/bin/ci_credentials.sh +++ b/tools/bin/ci_credentials.sh @@ -236,6 +236,7 @@ read_secrets source-lemlist "$SOURCE_LEMLIST_TEST_CREDS" read_secrets source-lever-hiring "$LEVER_HIRING_INTEGRATION_TEST_CREDS" read_secrets source-looker "$LOOKER_INTEGRATION_TEST_CREDS" read_secrets source-linkedin-ads "$SOURCE_LINKEDIN_ADS_TEST_CREDS" +read_secrets source-linnworks "$SOURCE_LINNWORKS_TEST_CREDS" read_secrets source-mailchimp "$MAILCHIMP_TEST_CREDS" read_secrets source-marketo "$SOURCE_MARKETO_TEST_CREDS" read_secrets source-microsoft-teams "$MICROSOFT_TEAMS_TEST_CREDS"