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
providesis["sync"]and there are no write handlers. - Identifiers.
idis Acme's immutable user id, so it comes first and carriesx-provider-stable-id;emailis a second identifier for matching. - Paging. The list call uses a body cursor named
next_cursorand parameterscursorandlimit, which are thecursorstyle's defaults, soctx.paginateneeds 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, soegress.manuallistsacmeprojects.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_profilehas"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_activereads any status other thandeactivatedas active, the same rulefetchOneuses forlifecycleState. - No
connection.validateConfig. The configuration schema'spatternalready 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.