Skip to main content

Example: minimal HTTP connector

A complete, sync-only HTTP connector for Acme Projects, a fictional project-management system. It reads users and nothing else. Start here to see the smallest connector that works; the example with Grant writes builds the full version.

Download it as a pack and import it into the Connector Builder or through the API: acme-projects-minimal.pack.json. This pack passes Owlie's publish validation, and its handlers type-check against the SDK.

The system​

Acme Projects has a REST API at https://<subdomain>.acmeprojects.example/api/v2, authenticated with an admin API token sent as Authorization: Bearer <token>. GET /users returns { "data": [...], "next_cursor": "..." } and accepts cursor and limit, and email to return only the user with exactly that address; GET /users/<id> returns one user or 404. A user's status is active or deactivated.

Decisions​

  • One entity, the account. Only users are synced, so provides is ["sync"] and there are no write handlers.
  • Identifiers. id is Acme's immutable user id, so it comes first and carries x-provider-stable-id; email is a second identifier for matching.
  • Paging. The list call uses a body cursor named next_cursor and parameters cursor and limit, which are the cursor style's defaults, so ctx.paginate needs only the path.
  • Base URL and egress. The host depends on the subdomain, so it is the auth option's apply.base_url. Owlie cannot read a host out of an expression, so egress.manual lists acmeprojects.example, which also allows every subdomain of it.
  • Accounts, not people. Acme Projects is a system people are given access to, so the account's sync_profile has "target": "RESOURCE": Sync links each user to the Owlie identity with the same email and flags a user that matches nobody, instead of creating identities.
  • Active or not. sync_profile.is_active reads any status other than deactivated as active, the same rule fetchOne uses for lifecycleState.
  • No connection.validateConfig. The configuration schema's pattern already refuses anything but a bare subdomain, and without the handler Owlie checks the install against the schema.
  • Its own key. The key is acme-projects-minimal, so you can import it beside the full example.

The files​

manifest.json

{
"key": "acme-projects-minimal",
"version": 1,
"name": "Acme Projects (minimal)",
"description": "Acme Projects users, synced.",
"icon": "@acmeprojects.example",
"kind": "http",
"provides": ["sync"],
"pagination_type": "cursor",
"config": {
"schema": {
"type": "object",
"required": ["subdomain"],
"properties": {
"subdomain": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z0-9-]+$",
"title": "Subdomain",
"description": "Your Acme Projects subdomain, for example acme in acme.acmeprojects.example.",
"x-suffix": ".acmeprojects.example"
}
},
"additionalProperties": false
}
},
"auth": [
{
"key": "api-token",
"title": "API token",
"description": "An Acme Projects admin API token.",
"config": {
"schema": {
"type": "object",
"required": ["api_token"],
"properties": {
"api_token": {
"type": "string",
"minLength": 1,
"title": "API token",
"format": "password",
"x-sensitive": true
}
},
"additionalProperties": false
}
},
"apply": {
"header": {
"Authorization": {
"expr": "'Bearer ' + config.api_token"
}
},
"base_url": {
"expr": "'https://' + config.subdomain + '.acmeprojects.example/api/v2'"
}
}
}
],
"egress": {
"manual": ["acmeprojects.example"]
},
"entities": {
"user": {
"name": "User",
"description": "An Acme Projects user. The account entity.",
"is_account": true,
"identifier_paths": ["id", "email"],
"sync_support": {
"supports_incremental": false,
"supports_deleted": false
},
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"title": "User ID",
"readOnly": true,
"x-identifier": true,
"x-provider-stable-id": true
},
"email": {
"type": "string",
"format": "email",
"title": "Email",
"x-identifier": true
},
"first_name": {
"type": "string",
"title": "First name"
},
"last_name": {
"type": "string",
"title": "Last name"
},
"status": {
"type": "string",
"enum": ["active", "deactivated"],
"title": "Status",
"readOnly": true
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created",
"readOnly": true
}
}
},
"sync_profile": {
"target": "RESOURCE",
"matchingRules": [
{
"identityField": "email",
"externalExpression": "email"
}
],
"is_active": "status != 'deactivated'"
}
}
},
"grant_kinds": []
}

handlers/connection/testConnection.ts

import type { Handler } from "../../types.gen.ts";
import { ConnectorSdkError } from "@owlie/connectors/sdk/v2";

/** Reads one user: proves the subdomain, the token and permission to read users. */
const testConnection: Handler<"connection.testConnection"> = async (_input, ctx) => {
try {
await ctx.http.get("/users", { ...ctx.options, query: { limit: 1 } });
return "connected";
} catch (error) {
throw new ConnectorSdkError(error, {
code: "CONFIGURATION_ERROR",
message: "Acme Projects connection test failed",
customerMessage: "Acme Projects could not verify the subdomain and API token.",
handledAt: "connector.invocation-context",
});
}
};

export default testConnection;

handlers/user/fetchAll.ts

import type { Handler, UserRecord } from "../../types.gen.ts";

const fetchAll: Handler<"user.fetchAll"> = async (options, ctx) => {
const page = await ctx.paginate<UserRecord>({ path: "/users", options });
for (const record of page.records) {
ctx.validate.resourceSchema("user", record, { mode: "warn", operation: "fetchAll", subject: "user record" });
}
return page;
};

export default fetchAll;

handlers/user/fetchOne.ts

import type { Handler, UserRecord } from "../../types.gen.ts";
import { isNotFound } from "@owlie/connectors/sdk/v2";
import { resolveUserId } from "../../lib/lookups.ts";

const fetchOne: Handler<"user.fetchOne"> = async (identifiers, ctx) => {
try {
const id = await resolveUserId(ctx, identifiers);
const response = await ctx.http.get<UserRecord>(`/users/${encodeURIComponent(id)}`, ctx.options);
return {
record: response.data,
metadata: { lifecycleState: response.data.status === "deactivated" ? "DISABLED" : "ACTIVE" },
};
} catch (error) {
if (isNotFound(error)) return { record: null };
throw error;
}
};

export default fetchOne;

lib/lookups.ts

import type { Context } from "../types.gen.ts";

/** The user's Acme id: taken as given when present, else looked up by email. A miss throws NOT_FOUND. */
export async function resolveUserId(ctx: Context, identifiers: { id?: string; email?: string }): Promise<string> {
if (identifiers.id) return identifiers.id;
if (!identifiers.email) throw ctx.errors.invalidInput("An Acme Projects user needs an id or an email");
const response = await ctx.http.get<{ data: { id: string }[] }>("/users", {
...ctx.options,
query: { email: identifiers.email, limit: 1 },
});
const user = response.data.data[0];
if (!user) throw ctx.errors.notFound("User", `No Acme Projects user has the email ${identifiers.email}`);
return user.id;
}

README.md and CHANGELOG.md complete the pack.

To grow this into a provisioning connector, continue with the example with Grant writes.