Skip to content

Latest commit

 

History

History
907 lines (694 loc) · 26.5 KB

File metadata and controls

907 lines (694 loc) · 26.5 KB

PyPI Version Python Versions MIT License

HrTech ETL (WIP)

Open-source ETL framework for HRTech data (jobs & profiles) across ATS, CRM, Jobboard, and HCM systems.

  • Focused on connectors (per external warehouse)
  • Uses Pydantic models for native & unified objects
  • Supports cursor-based incremental sync, pre-filtering, post-filtering
  • Supports pull & push pipelines for resources and events
  • Uses pluggable formatters (Python or mapping-based)
  • Metadata-driven query params: cursor_*, search_binding, in_binding
  • Ships with a FastAPI backend (API + Playground) and a CLI for scripting

👉 See also: CONTRIBUTING.md


Table of Contents

  1. Features
  2. Quick Start (Python)
    2.1. Pull: Jobs & Profiles
    2.2. Push: Native Resources
    2.3. Push: Events → Resources
    2.4. Custom Formatters
    2.5. Prefilters (WHERE) & Postfilters (HAVING)
    2.6. JSON / Mapping-based Formatters
  3. CLI Usage
  4. FastAPI App: API vs Playground
  5. Core Concepts
    5.1. Resources & Push Modes
    5.2. Connectors & Actions
    5.3. Native & Unified Models
    5.4. Cursor & Cursor Modes
    5.5. Formatters
    5.6. Conditions, Prefilters & UI Schema
    5.7. Query Param Bindings: cursor / search / IN
  6. Repository Structure
  7. Roadmap / Status
  8. Contributing
  9. License

Features

  • 🔌 Warehouse Connectors for ATS, CRM, Jobboard, HCM
  • 🧱 Pydantic-native models per warehouse (jobs & profiles)
  • 🧬 Optional UnifiedJob / UnifiedProfile / UnifiedJobEvent / UnifiedProfileEvent as normalized layer
  • 🔄 Cursor-based incremental pull on:
    • id
    • created_at
    • updated_at
  • 🎛️ Prefilters on origin (metadata-driven, via prefilter in json_schema_extra)
  • 🎚️ Postfilters in core on native origin objects (any field, richer operators)
  • 🧩 Formatter functions:
    • explicit native→native (e.g. WarehouseAJob → WarehouseBJob)
    • implicit native→unified→native via connector hooks
    • JSON-driven mapping formatters (built in the UI)
  • 🧷 Metadata-driven query params:
    • cursor_start_min / cursor_end_max / cursor_order_up / cursor_order_down
    • search_binding with field_join / value_join (e.g. (title OR text) AND (skills...))
    • in_binding for IN queries (e.g. board_keyboard_keys as array, csv, or array_string)
  • 📡 Push pipeline with two modes:
    • PushMode.RESOURCES → push native resources
    • PushMode.EVENTS → push from events (UnifiedJobEvent / UnifiedProfileEvent)
  • 🌐 FastAPI backend:
    • /api/... JSON endpoints (connectors, schema, pull/push, formatters)
    • /playground HTML UI for no-code mapping + pre/post filters + cursor control + events/resources JSON
  • 🖥 CLI for running pull/push jobs from the shell

Quick Start (Python)

2.1. Pull: Jobs & Profiles

The core pull primitive is:

from hrtech_etl.core.types import Resource, Cursor, CursorMode
from hrtech_etl.core.auth import ApiKeyAuth, BearerAuth
from hrtech_etl.core.pipeline import pull

from hrtech_etl.connectors.warehouse_a import WarehouseAConnector
from hrtech_etl.connectors.warehouse_b import WarehouseBConnector
from hrtech_etl.formatters import a_to_b

# --- Instantiate connectors ---

origin = WarehouseAConnector(
    auth=ApiKeyAuth(
        base_url="https://api.warehouse-a.example",
        header_name="X-API-Key",
        api_key="AAA",
        extra_headers={"X-Tenant-ID": "tenant-123"},
    )
)

target = WarehouseBConnector(
    auth=BearerAuth(
        base_url="https://api.warehouse-b.com",
        token="bbb"
    )
)

# start from scratch (no cursor yet)
cursor = Cursor(mode=CursorMode.UPDATED_AT, start=None, sort_by="asc")

# --- PULL JOBS: A -> B ---
cursor_jobs = pull(
    resource=Resource.JOB,
    origin=origin,
    target=target,
    cursor=cursor,
    formatter=a_to_b.format_job,   # JOB formatter (optional)
    batch_size=5000,
)

print("jobs cursor_start:", cursor_jobs.start)
print("jobs cursor_end:", cursor_jobs.end)

# --- PULL PROFILES: A -> B ---
cursor_profiles = pull(
    resource=Resource.PROFILE,
    origin=origin,
    target=target,
    cursor=cursor,
    formatter=a_to_b.format_profile,  # PROFILE formatter (optional)
    batch_size=5000,
)

print("profiles cursor_end:", cursor_profiles.end)

# Store cursor_jobs.end / cursor_profiles.end to resume on next run.

2.2. Push: Native Resources

Push directly from native resources (jobs or profiles):

from hrtech_etl.core.types import Resource, PushMode
from hrtech_etl.core.pipeline import push

# Assume you already have a list of native jobs from WarehouseA
jobs_to_push = [...]  # list[WarehouseAJob]

result = push(
    resource=Resource.JOB,
    origin=origin,
    target=target,
    mode=PushMode.RESOURCES,
    resources=jobs_to_push,
    events=None,
    having=None,                # optional postfilters on native origin objects
    formatter=a_to_b.format_job,
    batch_size=1000,
    dry_run=False,
)

print("pushed:", result.total_resources_pushed)
print("skipped_missing:", result.skipped_missing)
print("skipped_having:", result.skipped_having)
print("errors:", result.errors)

2.3. Push: Events → Resources

Push based on events (UnifiedJobEvent / UnifiedProfileEvent):

from hrtech_etl.core.models import UnifiedJobEvent
from hrtech_etl.core.types import Resource, PushMode
from hrtech_etl.core.pipeline import push

raw_events = read_raw_events_somewhere()

events: list[UnifiedJobEvent] = []
for raw in raw_events:
    ev = origin.parse_resource_event(Resource.JOB, raw)
    if ev is not None:
        events.append(ev)

result = push(
    resource=Resource.JOB,
    origin=origin,
    target=target,
    mode=PushMode.EVENTS,
    events=events,
    resources=None,
    having=None,                # optional postfilters on native origin jobs
    formatter=a_to_b.format_job,
    batch_size=1000,
    dry_run=False,
)

print("total events:", result.total_events)
print("resources fetched:", result.total_resources_fetched)
print("pushed:", result.total_resources_pushed)

Under the hood for PushMode.EVENTS:

  1. Connector translates unified events → native fetch:

    • origin.fetch_resources_by_events(Resource.JOB, events)
  2. Core applies having (postfilters) to native jobs/profiles.

  3. Core uses safe_format_resources(...) to:

    • either call your formatter directly,
    • or fallback to unified (origin-native → Unified → target-native) if formatter is None.
  4. Core calls target.write_resources_batch(Resource.JOB, formatted_resources).


2.4. Custom Formatters

You can write your own Python formatter for each resource:

from hrtech_etl.connectors.warehouse_a.models import WarehouseAJob, WarehouseAProfile
from hrtech_etl.connectors.warehouse_b.models import WarehouseBJob, WarehouseBProfile

def format_job(job: WarehouseAJob) -> WarehouseBJob:
    return WarehouseBJob(
        job_id=job.job_id,
        title=job.title,
        created_at=job.created_at,
        updated_at=job.updated_at,
        payload=job.payload,
    )

def format_profile(profile: WarehouseAProfile) -> WarehouseBProfile:
    return WarehouseBProfile(
        profile_id=profile.profile_id,
        full_name=profile.full_name,
        created_at=profile.created_at,
        updated_at=profile.updated_at,
        payload=profile.payload,
    )

cursor_jobs = pull(
    resource=Resource.JOB,
    origin=origin,
    target=target,
    cursor=cursor,
    formatter=format_job,
)

If formatter is None, the core automatically:

  • converts origin-native → unified (to_unified_job / to_unified_profile)
  • then unified → target-native (from_unified_job / from_unified_profile).

2.5. Prefilters (WHERE) & Postfilters (HAVING)

Prefilters (origin WHERE)

Prefilters are pushed down to the origin warehouse via Prefilter(...) plus field metadata (json_schema_extra["prefilter"]).

from datetime import datetime
from hrtech_etl.core.expressions import Prefilter
from hrtech_etl.core.types import Resource, Cursor, CursorMode
from hrtech_etl.connectors.warehouse_a.models import WarehouseAJob

prefilters = [
    Prefilter(WarehouseAJob, "title").contains("engineer"),
    Prefilter(WarehouseAJob, "created_at").gte(datetime(2024, 1, 1)),
]

cursor = Cursor(mode=CursorMode.UPDATED_AT, start=None, sort_by="asc")

cursor_jobs = pull(
    resource=Resource.JOB,
    origin=origin,
    target=target,
    cursor=cursor,
    where=prefilters,      # prefilters (optional)
    having=None,
    formatter=None,
)

Example of field metadata:

from pydantic import BaseModel, Field

class WarehouseAJob(BaseModel):
    title: str = Field(
        ...,
        json_schema_extra={
            "prefilter": {"operators": ["eq", "contains"]},
        },
    )
    created_at: datetime = Field(
        ...,
        json_schema_extra={
            "cursor": "created_at",
            "prefilter": {"operators": ["gte", "lte"]},
        },
    )

Postfilters (origin HAVING)

Postfilters are applied in memory on native origin objects:

from hrtech_etl.core.types import Condition, Operator

postfilters = [
    Condition(field="title", op=Operator.CONTAINS, value="Senior"),
]

cursor_jobs = pull(
    resource=Resource.JOB,
    origin=origin,
    target=target,
    cursor=cursor,
    where=prefilters or None,
    having=postfilters or None,
    formatter=None,
)

For postfilters:

  • All fields are eligible.
  • All operators are available (EQ, GT, GTE, LT, LTE, IN, CONTAINS).
  • No extra metadata required.

2.6. JSON / Mapping-based Formatters

You can build a formatter from a mapping spec. This is what the UI uses behind the scenes.

from hrtech_etl.formatters.base import build_mapping_formatter

mapping = [
    {"from": "job_id", "to": "id"},
    {"from": "title",  "to": "name"},
]

formatter = build_mapping_formatter(mapping)

cursor_jobs = pull(
    resource=Resource.JOB,
    origin=origin,
    target=target,
    cursor=cursor,
    formatter=formatter,
)

build_mapping_formatter(mapping) returns a callable:

def formatter(origin_obj) -> dict:
    data = {}
    for item in mapping:
        src = item["from"]
        dst = item["to"]
        data[dst] = getattr(origin_obj, src, None)
    return data

In the API layer, we store mapping specs in FORMATTER_REGISTRY with a formatter_id, and rebuild the formatter at runtime.


CLI Usage

The CLI lives in hrtech_etl/cli.py and exposes commands like pull-cmd.

Typical usage (from the project root):

python -m hrtech_etl.cli pull-cmd \
  --resource job \
  --origin warehouse_a \
  --target warehouse_b \
  --cursor-mode updated_at \
  --cursor-start "2024-01-01T00:00:00Z" \
  --cursor-sort-by asc \
  --batch-size 1000 \
  --dry-run True

You can also pass WHERE and HAVING as JSON lists of conditions:

python -m hrtech_etl.cli pull-cmd \
  --resource job \
  --origin warehouse_a \
  --target warehouse_b \
  --cursor-mode updated_at \
  --cursor-start "2024-01-01T00:00:00Z" \
  --cursor-sort-by asc \
  --where '[
    {"field": "board_key", "op": "in",       "value": ["board-1", "board-2"]},
    {"field": "name",      "op": "contains", "value": "engineer"}
  ]' \
  --having '[
    {"field": "updated_at", "op": "gte", "value": "2024-02-01T00:00:00Z"}
  ]' \
  --batch-size 1000 \
  --dry-run True

Internally:

  • _parse_conditions(...) converts each JSON object into a Condition.
  • pull(...) receives where and having exactly like the Python API.

Once packaged, you can expose this as an entry-point (hrtech-etl pull-cmd ...) via pyproject.toml if you want.


FastAPI App: API vs Playground

The FastAPI app lives in app/ and can expose:

  • API (JSON endpoints)
  • Playground (HTML UI)
  • Or both

app/main.py builds a single FastAPI instance and wires routers based on HRTECH_ETL_MODE:

# app/main.py (simplified)
import os
from fastapi import FastAPI
from .api import router as api_router
from .playground import router as playground_router

def create_app() -> FastAPI:
    app = FastAPI(title="hrtech-etl")

    mode = os.getenv("HRTECH_ETL_MODE", "both").lower()
    # "api" | "playground" | "both"

    if mode in ("api", "both"):
        app.include_router(api_router, prefix="/api", tags=["api"])

    if mode in ("playground", "both"):
        app.include_router(playground_router, tags=["playground"])

    return app

app = create_app()

Run in different modes

# API only (JSON endpoints)
HRTECH_ETL_MODE=api uvicorn app.main:app --reload

# Playground only (HTML UI at /playground)
HRTECH_ETL_MODE=playground uvicorn app.main:app --reload

# Both API + Playground
HRTECH_ETL_MODE=both uvicorn app.main:app --reload

API Highlights

  • GET /api/connectors → list registered connectors (name, label, warehouse_type)

  • GET /api/schema/{connector_name}/{resource}?only_prefilterable=false → native model fields (with cursor/prefilter metadata if declared)

  • GET /api/schema/unified/{resource} → unified model fields (job/profile)

  • POST /api/run/pull → run a pull with ResourcePullConfig

  • POST /api/run/push → run a push with ResourcePushConfig

  • POST /api/formatters/build → store a mapping spec and return a formatter_id

  • POST /api/run/pull_with_formatter / POST /api/run/push_with_formatter → same as above, but using a stored formatter_id.

Playground Highlights

/playground provides:

  • Select origin / target connector

  • Select resource (job, profile)

  • Select operation (pull, push)

  • For pull:

    • set cursor mode & cursor start / direction
    • configure mapping (origin→target)
    • set prefilters (WHERE) and postfilters (HAVING)
  • For push:

    • choose PushMode.RESOURCES or PushMode.EVENTS
    • paste Resources JSON (native objects) for RESOURCES mode
    • paste Events JSON (UnifiedJobEvent / UnifiedProfileEvent) for EVENTS mode

Core Concepts

5.1. Resources & Push Modes

In core/types.py:

  • Resource enum:

    • Resource.JOB
    • Resource.PROFILE
  • PushMode enum:

    • PushMode.RESOURCES
    • PushMode.EVENTS

All pull/push operations are parameterized by resource.


5.2. Connectors & Actions

  • BaseConnector (core/connector.py):

    • Knows its native job & profile models:

      • job_native_cls
      • profile_native_cls
    • Implements generic resource methods:

      • read_resources_batch(resource, cursor, where, batch_size)
      • write_resources_batch(resource, resources)
      • get_resource_id(resource, native)
      • parse_resource_event(resource, raw)
      • fetch_resources_by_events(resource, events)
  • Per-warehouse connectors (connectors/warehouse_a, connectors/warehouse_b, ...):

    • Implement BaseConnector for their system.
    • Use an Actions class (e.g. WarehouseAActions) to perform concrete HTTP / DB / SDK calls.
    • Use build_connector_params(...) to translate unified Conditions + Cursor into backend query params based on model metadata.

5.3. Native & Unified Models

  • Native models: e.g. WarehouseAJob, WarehouseAProfile

    • Pydantic models with connector-specific fields.

    • Use json_schema_extra to annotate:

      • cursor metadata
      • prefilter operators
      • search binding
      • IN binding
  • Unified models (core/models.py):

    • UnifiedJob, UnifiedProfile
    • UnifiedJobEvent, UnifiedProfileEvent
    • Provide a standardized layer when no explicit formatter is provided.

Connectors implement:

  • to_unified_job(...) / from_unified_job(...)
  • to_unified_profile(...) / from_unified_profile(...)

5.4. Cursor & Cursor Modes

Cursor and CursorMode (in core/types.py):

class CursorMode(str, Enum):
    UID = "id"
    CREATED_AT = "created_at"
    UPDATED_AT = "updated_at"

class Cursor(BaseModel):
    mode: CursorMode
    start: str | None = None
    end: str | None = None
    sort_by: str = "asc"  # "asc" | "desc"

Unified models and native models declare cursor fields via json_schema_extra:

from pydantic import BaseModel, Field
from hrtech_etl.core.types import CursorMode

class UnifiedJob(BaseModel):
    created_at: Optional[str] = Field(
        ...,
        json_schema_extra={
            "cursor": CursorMode.CREATED_AT.value,
            "cursor_start_min": "date_range_min",
            "cursor_end_max": "date_range_max",
            "cursor_order_up": "asc",
            "cursor_order_down": "desc",
            "prefilter": {"operators": ["gte", "lte"]},
        },
    )

build_cursor_query_params(...) in core/utils.py reads those metadata and builds:

  • the correct param names for ranges (date_range_min, date_range_max or fallbacks)
  • consistent handling of asc / desc across start and end.

5.5. Formatters

In formatters/base.py:

  • JobFormatter / ProfileFormatter Protocols
  • build_mapping_formatter(mapping) to build a dict-based formatter
  • FORMATTER_REGISTRY to store mapping specs in memory for the API & Playground

Core uses a single helper:

safe_format_resources(
    resource,
    origin,
    target,
    formatter,
    native_resources,
)

Behavior:

  • If formatter is provided:

    • For each native resource, call formatter(...).

    • If it returns:

      • a BaseModel → used as is.
      • a dict → wrapped into the target native model.
  • If formatter is None:

    • Fallback path: origin-native → unified → target-native.

5.6. Conditions, Prefilters & UI Schema

  • Condition / Operator (in core/types.py) model filter expressions.

  • Prefilter (in core/expressions.py) builds metadata-aware prefilters on a given model.

  • export_model_fields(model_cls, only_prefilterable) (in core/ui_schema.py) exposes:

    • name: field name
    • type: Python type name
    • cursor: cursor tag (if any)
    • prefilter: config (operators, etc.)

This is used by:

  • the API (/api/schema/...)
  • the Playground UI to populate dropdowns.

5.7. Query Param Bindings: cursor / search / IN

The generic query param builder lives in core/utils.py:

build_connector_params(
    resource_cls: Type[BaseModel],
    where: Optional[List[Condition]],
    cursor: Optional[Cursor],
    *,
    sort_by_unified: Optional[str],
    sort_param_name: Optional[str],
) -> Dict[str, Any]

Under the hood it orchestrates:

  • build_eq_query_params(...)
  • build_in_query_params(...)
  • build_search_query_params(...)
  • build_cursor_query_params(...)

IN Binding (in_binding)

In models:

board_key: str = Field(
    ...,
    json_schema_extra={
        "prefilter": {"operators": ["in"]},
        "in_binding": {
            "query_field": "board_keys",   # HTTP query name
            "formatter": "string_array",   # "array" | "csv" | "array_string"
        },
    },
)

If query_field is omitted, default is field__in. If formatter is omitted, default is array (Python list).

build_in_query_params(...) handles grouping and formatting.

Search Binding (search_binding)

In unified models:

from hrtech_etl.core.types import BoolJoin

name: str = Field(
    ...,
    json_schema_extra={
        "prefilter": {"operators": ["contains"]},
        "search_binding": {
            "search_field": "keywords",
            "field_join": BoolJoin.OR,    # how this field joins other fields
            "value_join": BoolJoin.AND,   # how multiple values on this field are joined
        },
    },
)

text: str = Field(
    ...,
    json_schema_extra={
        "search_binding": {
            "search_field": "keywords",
            "field_join": BoolJoin.AND,
            "value_join": BoolJoin.OR,
        },
    },
)

Given WHERE conditions like:

  • name CONTAINS "data"
  • text CONTAINS "science"
  • skills CONTAINS ["python", "sql"] (with its own binding)

build_search_query_params(...) will produce something like:

{
  "keywords": "(data) AND (science) AND (python OR sql)"
}

depending on field_join / value_join per field.


Repository Structure

hrtech-etl/
├─ pyproject.toml
├─ README.md
├─ CONTRIBUTING.md
├─ LICENSE
├─ cli.py                      # CLI entrypoint (pull_cmd, push_cmd using core.pipeline)
│
├─ src/
│  └─ hrtech_etl/
│     ├─ __init__.py
│     │
│     ├─ core/
│     │  ├─ __init__.py
│     │  ├─ auth.py            # BaseAuth, ApiKeyAuth, BearerAuth, ...
│     │  ├─ types.py           # Resource, WarehouseType, Cursor, CursorMode, Condition, Operator,
│     │  │                     # PushMode, PushResult, BoolJoin, Formatter, ...
│     │  ├─ models.py          # UnifiedJob, UnifiedProfile, UnifiedJobEvent, UnifiedProfileEvent
│     │  │                     # + metadata: prefilter, search_binding, in_binding, cursor_*
│     │  ├─ connector.py       # BaseConnector (generic jobs/profiles/events abstraction)
│     │  ├─ expressions.py     # Prefilter(...) → metadata-aware Condition builders
│     │  ├─ ui_schema.py       # export_model_fields(...) for UI (cursor + prefilter metadata)
│     │  ├─ utils.py           # safe_format_resources, apply_postfilters,
│     │  │                     # get_cursor_native_name/value, build_eq_query_params,
│     │  │                     # build_in_query_params, build_search_query_params,
│     │  │                     # build_cursor_query_params, build_connector_params
│     │  ├─ registry.py        # ConnectorMeta, register_connector, get_connector_instance
│     │  └─ pipeline.py        # pull(...), push(...), ResourcePullConfig, ResourcePushConfig,
│     │                        # run_resource_pull_from_config(...), run_resource_push_from_config(...)
│     │
│     ├─ connectors/
│     │  ├─ __init__.py
│     │  ├─ warehouse_a/
│     │  │  ├─ __init__.py     # WarehouseAConnector implementation + registration via ConnectorMeta
│     │  │  ├─ models.py       # WarehouseAJob, WarehouseAProfile, WarehouseAJobEvent, WarehouseAProfileEvent
│     │  │  ├─ actions.py      # WarehouseAActions (low-level HTTP/DB/SDK client using build_connector_params)
│     │  │  └─ test.py         # Merged tests:
│     │  │                     #  - direct pull(...) with DummyActions
│     │  │                     #  - FastAPI integration tests via TestClient (api.run_pull / api.run_push)
│     │  └─ warehouse_b/
│     │     ├─ __init__.py     # (placeholder / example connector)
│     │     ├─ models.py       # (placeholder native models)
│     │     ├─ actions.py      # (placeholder actions client)
│     │     └─ test.py         # (optional example tests or left minimal)
│     │
│     └─ formatters/
│        ├─ __init__.py
│        ├─ base.py            # FORMATTER_REGISTRY, JobFormatter/ProfileFormatter Protocols,
│        │                     # build_mapping_formatter(mapping) for mapping-based formatters
│        └─ a_to_b.py          # Example formatter: WarehouseA → WarehouseB (job/profile mapping)
│
├─ app/
│  ├─ __init__.py
│  ├─ api.py                   # JSON API routes:
│  │                           #  - /api/connectors
│  │                           #  - /api/schema/{connector}/{resource}
│  │                           #  - /api/schema/unified/{resource}
│  │                           #  - /api/run/pull
│  │                           #  - /api/run/push
│  │                           #  - /api/formatters/build, /api/formatters/{id},
│  │                           #  - /api/run/pull_with_formatter, /api/run/push_with_formatter
│  ├─ playground.py            # HTML playground:
│  │                           #  - configure origin/target, resource, cursor
│  │                           #  - build mapping, prefilters (WHERE), postfilters (HAVING)
│  │                           #  - push RESOURCES / EVENTS via pasted JSON
│  ├─ main.py                  # create_app() using HRTECH_ETL_MODE = api | playground | both
│  └─ templates/
│     └─ playground.html       # Jinja2 template powering the playground UI
│
└─ tests/
   └─ (empty for now)          # Reserved for future core / integration tests
                               # Connector-specific tests live close to connectors

Roadmap / Status

This is a WIP (work in progress).

Planned / in-progress:

  • DevOps v1: local integration environment (Poetry / uv)
  • DevOps v2: GitHub workflow + package release bumping through PR
  • Real-world ATS / CRM / Jobboard / HCM connectors
  • Better type coercion for filters (dates, ints, enums)
  • Worker-style pull pipeline + event hooks for push
  • Add MCP / Agent integrations
  • Expand test coverage & CI (lint, type-check, e2e scenarios)

Contributing

Contributions are very welcome ❤️

  • Add a new connector (ATS / CRM / Jobboard / HCM)
  • Extend the playground
  • Add new filter operators or cursor strategies
  • Improve docs and examples

See CONTRIBUTING.md for:

  • repo layout
  • dev setup
  • coding guidelines
  • how to add a connector
  • how to extend the API / playground
  • how to submit a PR

License

Distributed under the MIT License. See LICENSE for details.