1
0
mirror of synced 2025-12-25 02:09:19 -05:00

🎉 New Source: Linnworks (#7588)

This commit is contained in:
Juozas V
2021-11-15 19:58:29 +02:00
committed by GitHub
parent c8e206d68f
commit ea738f1680
37 changed files with 3955 additions and 1 deletions

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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"

View File

@@ -42,6 +42,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) |

View File

@@ -0,0 +1,7 @@
*
!Dockerfile
!Dockerfile.test
!main.py
!source_linnworks
!setup.py
!secrets

View File

@@ -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

View File

@@ -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.

View File

@@ -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"

View File

@@ -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

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -0,0 +1,3 @@
#
# Copyright (c) 2021 Airbyte, Inc., all rights reserved.
#

View File

@@ -0,0 +1,5 @@
{
"processed_orders": {
"dReceivedDate": "2050-01-01T00:00:00+00:00"
}
}

View File

@@ -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

View File

@@ -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"]]
}
]
}

View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,6 @@
{
"application_id": "invalid_id",
"application_secret": "invalid_secret",
"token": "invalid_token",
"start_date": "not-a-date"
}

View File

@@ -0,0 +1,6 @@
{
"application_id": "the_id",
"application_secret": "the_secret",
"token": "the_token",
"start_date": "2021-01-01T00:00:00+00:00"
}

View File

@@ -0,0 +1,5 @@
{
"processed_orders": {
"dReceivedDate": "2021-01-01T00:00:00+00:00"
}
}

View File

@@ -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:])

View File

@@ -0,0 +1,2 @@
-e ../../bases/source-acceptance-test
-e .

View File

@@ -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,
},
)

View File

@@ -0,0 +1,8 @@
#
# Copyright (c) 2021 Airbyte, Inc., all rights reserved.
#
from .source import SourceLinnworks
__all__ = ["SourceLinnworks"]

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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."
}
}
}
}
}

View File

@@ -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"]),
]

View File

@@ -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"
}
}
}
}

View File

@@ -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<StockLocation> 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<StockItemFull> 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

View File

@@ -0,0 +1,3 @@
#
# Copyright (c) 2021 Airbyte, Inc., all rights reserved.
#

View File

@@ -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))

View File

@@ -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

View File

@@ -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()

View File

@@ -77,6 +77,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)
@@ -128,7 +129,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)

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -237,6 +237,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"