Example: HTTP connector with Grant writes
The complete Acme Projects connector: it syncs users and teams, creates, updates, deactivates and reactivates users, and adds and removes team memberships with their role. It follows every rule in Grant writes.
Download it as a pack and import it into the Connector Builder or through the API: acme-projects.pack.json. This pack passes Owlie's publish validation, and its handlers type-check against the SDK.
The system
On top of the minimal example's users API, Acme Projects has:
| Call | Does | Answers |
|---|---|---|
POST /users | Create a user from email, first_name, last_name | The user |
PATCH /users/<id> | Change a user | The user |
POST /users/<id>/deactivate, /reactivate | Deactivate or reactivate | The user |
GET /teams, GET /teams/<id> | List or read teams | Teams |
GET /teams/<id>/members | List a team's members with their role | { "data": [{ "user_id", "role" }], "next_cursor" } |
GET /teams/<id>/members/<user_id> | Read one membership | The membership, or 404 |
POST /teams/<id>/members | Add a member with a role | 201, or 409 { "error": { "code": "already_member" } } |
PATCH /teams/<id>/members/<user_id> | Change a member's role | 200 |
DELETE /teams/<id>/members/<user_id> | Remove a member | 204, or 404 { "error": { "code": "not_a_member" } } |
A user holds one role per team, member or admin.
Decisions
- Entities.
useris the account;teamis the target of access. - The Grant kind.
team_memberruns from user to team. Sync lists each team's members, so it is discovered viateamwithfetch_mode: "per-entity". The role is a required enum qualifier. It is not a slot key, because a user holds one role per team: changing it replaces the old one. Nothing in this connector holds access on a team's behalf, so the Grant kind has nomembershipflag. - Writable. The API has an add and a remove, Sync can list memberships, and the documented answers
for "already a member" and "not a member" are known, so
writableistrue. - Add is honest about the role. A 409 says a membership exists but not with which role. The add
handler reads the membership: the same role is
alreadyPresent(), a different role is changed in place. - Remove trusts only the documented answer.
lib/map-error.tsturns the delete's 404not_a_memberintoNOT_MEMBER, and only that code returnsalreadyAbsent(). Any other 404, such as an unknown team or a mistyped path, fails the remove, and so does a user lookup that finds nobody. - Lifecycle. Acme deactivates rather than deletes, so there are
disableandenablehandlers and nodelete, and the provisioning profile ends a revoked accountDISABLED. - New accounts get their email and names from the identity. No domain is hard-coded.
- Lookups. Handlers receive whichever identifiers the caller has.
lib/lookups.tsresolves a user by id, or by email (GET /users?email=) when only the email is known, and fails when nobody matches. - Base URL, egress and account sync are declared as in the minimal example:
egress.manualallowsacmeprojects.exampleand its subdomains, and the account'ssync_profilelinks users to identities by email ("target": "RESOURCE") and reads any status other thandeactivatedas active.
The files
manifest.json
{
"key": "acme-projects",
"version": 1,
"name": "Acme Projects",
"description": "Acme Projects users and teams, synced and provisioned, with team membership writes.",
"icon": "@acmeprojects.example",
"kind": "http",
"provides": ["sync", "provision"],
"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,
"x-provisioning": {
"requiredOn": ["PROVISION"]
}
},
"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'"
},
"provisioning_profile": {
"version": 1,
"operations": {
"PROVISION": {
"provision_mode": "direct",
"baseline": {
"attributes": {
"email": {
"type": "expression",
"value": "identity.email"
},
"first_name": {
"type": "expression",
"value": "identity.first_name"
},
"last_name": {
"type": "expression",
"value": "identity.last_name"
}
},
"grants": []
},
"before": [],
"after": []
},
"REVOKE": {
"remove_managed_grants": true,
"lifecycle_state_on_revoke": "DISABLED",
"before": [],
"after": []
},
"DISABLE": {
"before": [],
"after": []
},
"ENABLE": {
"before": [],
"after": []
},
"UPDATE_ATTRIBUTES": {
"before": [],
"after": []
},
"ADD_GRANTS": {
"before": [],
"after": []
},
"REMOVE_GRANTS": {
"before": [],
"after": []
},
"SET_GRANTS": {
"before": [],
"after": []
}
}
}
},
"team": {
"name": "Team",
"description": "An Acme Projects team.",
"identifier_paths": ["id"],
"sync_support": {
"supports_incremental": false,
"supports_deleted": false
},
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"title": "Team ID",
"readOnly": true,
"x-identifier": true
},
"name": {
"type": "string",
"title": "Name"
},
"description": {
"type": "string",
"title": "Description"
}
}
}
}
},
"grant_kinds": [
{
"key": "team_member",
"source": "user",
"target": "team",
"discovered_via": "team",
"fetch_mode": "per-entity",
"writable": true,
"qualifiers": [
{
"name": "role",
"schema": "enum",
"values": ["member", "admin"],
"order": ["member", "admin"],
"default": "member",
"required": true
}
]
}
]
}
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/connection/validateConfig.ts
import type { Handler } from "../../types.gen.ts";
import { isRecord } from "@owlie/connectors/sdk/v2";
/** The schema's pattern refuses a full host too; this answers with a message that says what to enter. */
const validateConfig: Handler<"connection.validateConfig"> = async (input) => {
const config = isRecord(input) && isRecord(input.config) ? input.config : {};
const subdomain = config.subdomain;
if (typeof subdomain !== "string" || !/^[a-z0-9-]+$/.test(subdomain)) {
return { valid: false, errors: ["Enter only the part before .acmeprojects.example, for example acme."] };
}
return { valid: true };
};
export default validateConfig;
handlers/grants/team_member/add.ts
import type { AcmeMember } from "../../../lib/types.ts";
import type { Handler } from "../../../types.gen.ts";
import { isAlreadyExists } from "@owlie/connectors/sdk/v2";
import { resolveTeamId, resolveUserId } from "../../../lib/lookups.ts";
/**
* POST adds the membership. A 409 `already_member` (mapped to ALREADY_MEMBER by lib/map-error.ts)
* says a membership exists, but not with which role, so read it: the same role is already present,
* a different role is changed in place, since a user holds one role per team.
*/
const add: Handler<"grant.team_member.add"> = async (input, ctx) => {
const userId = await resolveUserId(ctx, input.principal.identifiers);
const teamId = resolveTeamId(ctx, input.target.identifiers);
const role = input.qualifiers.role;
const members = `/teams/${encodeURIComponent(teamId)}/members`;
try {
await ctx.http.post(members, { ...ctx.options, json: { user_id: userId, role } });
} catch (error) {
if (!isAlreadyExists(error)) throw error;
const member = `${members}/${encodeURIComponent(userId)}`;
const existing = await ctx.http.get<AcmeMember>(member, ctx.options);
if (existing.data.role === role) return ctx.grant.alreadyPresent();
await ctx.http.patch(member, { ...ctx.options, json: { role } });
}
};
export default add;
handlers/grants/team_member/remove.ts
import type { Handler } from "../../../types.gen.ts";
import { applicationCodeOf } from "@owlie/connectors/sdk/v2";
import { resolveTeamId, resolveUserId } from "../../../lib/lookups.ts";
/**
* DELETE removes the membership. lib/map-error.ts turns Acme's documented 404 `not_a_member` into
* NOT_MEMBER, the only answer that proves the membership is gone. Any other 404 (an unknown team or
* user, a mistyped path) keeps its NOT_FOUND code and fails the remove.
*/
const remove: Handler<"grant.team_member.remove"> = async (input, ctx) => {
const userId = await resolveUserId(ctx, input.principal.identifiers);
const teamId = resolveTeamId(ctx, input.target.identifiers);
try {
await ctx.http.delete(`/teams/${encodeURIComponent(teamId)}/members/${encodeURIComponent(userId)}`, ctx.options);
} catch (error) {
if (applicationCodeOf(error) === "NOT_MEMBER") return ctx.grant.alreadyAbsent();
throw error;
}
};
export default remove;
handlers/team/fetchAll.ts
import type { Handler, TeamRecord } from "../../types.gen.ts";
const fetchAll: Handler<"team.fetchAll"> = (options, ctx) => ctx.paginate<TeamRecord>({ path: "/teams", options });
export default fetchAll;
handlers/team/fetchOne.ts
import type { Handler, TeamRecord } from "../../types.gen.ts";
import { isNotFound } from "@owlie/connectors/sdk/v2";
import { resolveTeamId } from "../../lib/lookups.ts";
const fetchOne: Handler<"team.fetchOne"> = async (identifiers, ctx) => {
try {
const id = resolveTeamId(ctx, identifiers);
const response = await ctx.http.get<TeamRecord>(`/teams/${encodeURIComponent(id)}`, ctx.options);
return { record: response.data };
} catch (error) {
if (isNotFound(error)) return { record: null };
throw error;
}
};
export default fetchOne;
handlers/team/fetchRelationships.ts
import type { AcmeMember } from "../../lib/types.ts";
import type { Handler } from "../../types.gen.ts";
const fetchRelationships: Handler<"team.fetchRelationships"> = async (teamId, options, ctx) => {
const page = await ctx.paginate<AcmeMember>({ path: `/teams/${encodeURIComponent(teamId)}/members`, options });
return {
...page,
records: page.records.map((member) => ({
edgeKey: "team_member",
sourceEntityKey: "user",
sourceExternalId: member.user_id,
targetEntityKey: "team",
targetExternalId: teamId,
attrs: { role: member.role },
})),
};
};
export default fetchRelationships;
handlers/user/create.ts
import type { Handler, UserRecord } from "../../types.gen.ts";
const create: Handler<"user.create"> = async (input, ctx) => {
const response = await ctx.http.post<UserRecord>("/users", {
...ctx.options,
json: { email: input.email, first_name: input.first_name, last_name: input.last_name },
});
ctx.validate.resourceSchema("user", response.data, { mode: "warn", operation: "create", subject: "user response" });
return response.data;
};
export default create;
handlers/user/disable.ts
import type { Handler } from "../../types.gen.ts";
import { resolveUserId } from "../../lib/lookups.ts";
const disable: Handler<"user.disable"> = async (identifiers, ctx) => {
const id = await resolveUserId(ctx, identifiers);
await ctx.http.post(`/users/${encodeURIComponent(id)}/deactivate`, ctx.options);
};
export default disable;
handlers/user/enable.ts
import type { Handler } from "../../types.gen.ts";
import { resolveUserId } from "../../lib/lookups.ts";
const enable: Handler<"user.enable"> = async (identifiers, ctx) => {
const id = await resolveUserId(ctx, identifiers);
await ctx.http.post(`/users/${encodeURIComponent(id)}/reactivate`, ctx.options);
};
export default enable;
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;
handlers/user/update.ts
import type { Handler, UserRecord } from "../../types.gen.ts";
import { resolveUserId } from "../../lib/lookups.ts";
const update: Handler<"user.update"> = async (identifiers, input, ctx) => {
const id = await resolveUserId(ctx, identifiers);
const response = await ctx.http.patch<UserRecord>(`/users/${encodeURIComponent(id)}`, {
...ctx.options,
json: input,
});
ctx.validate.resourceSchema("user", response.data, { mode: "warn", operation: "update", subject: "user response" });
return response.data;
};
export default update;
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;
}
/** The team's Acme id. Teams are addressed by id only. */
export function resolveTeamId(ctx: Context, identifiers: { id?: string }): string {
if (!identifiers.id) throw ctx.errors.invalidInput("An Acme Projects team needs its id");
return identifiers.id;
}
lib/map-error.ts
import type { MapError } from "../types.gen.ts";
import { ConnectorApplicationError, isHttpResponseError } from "@owlie/connectors/sdk/v2";
/**
* Acme Projects documents two membership answers: `{ "error": { "code": "already_member" } }` (409)
* when an add finds the membership, and `{ "error": { "code": "not_a_member" } }` (404) when a remove
* does not. Only these become ALREADY_MEMBER and NOT_MEMBER; every other error keeps its own code.
*/
export const mapError: MapError = (error) => {
if (!isHttpResponseError(error)) return;
if (error.details?.providerCode === "already_member") {
return new ConnectorApplicationError({
code: "ALREADY_MEMBER",
resource: "MEMBERSHIP",
message: "Already a member",
});
}
if (error.details?.providerCode === "not_a_member") {
return new ConnectorApplicationError({ code: "NOT_MEMBER", resource: "MEMBERSHIP", message: "Not a member" });
}
};
lib/types.ts
/** Acme Projects' list responses: `{ "data": [...], "next_cursor": "..." }`. */
export type AcmeMember = { user_id: string; role: "member" | "admin" };
README.md and CHANGELOG.md complete the pack.