feat: Notion workspace integration (#30)#77
Open
jackulau wants to merge 1 commit into
Open
Conversation
Implements the Notion integration node pack requested in issue wespreadjam#30: bearer credential plus four operations (create page, update page, query database, append blocks) with Zod schemas and unit tests. - credentials.ts: defineBearerCredential with notion Integration Token, apiToken schema rejects empty + control characters (CRLF defense) - schemas.ts: shared NotionBlockSchema / NotionPropertiesSchema / NotionFilterSchema / NotionSortSchema / NotionPageObjectSchema, plus buildNotionHeaders (pins Notion-Version: 2022-06-28) and formatNotionError (parses FetchRetryError body to surface Notion code/message in the final error string) - notion-create-page: POST /v1/pages, forwards properties/children/icon/cover - notion-update-page: PATCH /v1/pages/{pageId} with encodeURIComponent, refine rejects no-op updates, present-but-empty properties accepted - notion-query-database: POST /v1/databases/{databaseId}/query, camelCase output (hasMore, nextCursor) from snake_case API response, pageSize defaults to 100 with min(1)/max(100) bounds - notion-append-blocks: PATCH /v1/blocks/{blockId}/children, children bounded 1-100 (Notion per-request cap) - NodeCredentials gains notion?: { apiToken: string } - integrations/index.ts re-exports all four nodes + schemas + types All four executors follow the existing apify/slack pattern: pure defineNode calls using fetchWithRetry, returning { success, output } or { success: false, error } — no cross-cutting concerns leak into node code (per CLAUDE.md architectural decision). 60 vitest tests cover: credential metadata, schema validation (including CRLF rejection, boundary conditions, defaults, no-op refinement), happy paths with URL/method/header/body assertions, error paths (400/401/403/404/409/429), URL encoding for path parameters, cursor pagination boundaries (empty results with has_more: true, null next_cursor), and the 100-block append cap.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the Notion workspace integration requested in #30. Adds a bearer credential plus four pure-node operations (create page, update page, query database, append blocks), all using the existing
defineBearerCredential/defineNode/fetchWithRetryprimitives so the package lands consistently with Apify, Slack, and the rest of the integrations directory.Closes #30.
Acceptance Criteria
What's in the diff
New package
packages/nodes/src/integrations/notion/credentials.tsnotionCredentialviadefineBearerCredential, headerAuthorization: Bearer {{apiToken}}.apiTokenschema isz.string().min(1)+ regex that rejects\r,\n,\t,\v,\f,\0to harden against header injection.schemas.tsNotionBlockSchema,NotionPropertiesSchema,NotionFilterSchema,NotionSortSchema,NotionPageObjectSchema(uses.passthrough()so unknown Notion response fields don't fail validation across API versions). Per-operation input/output schemas. Shared helpersbuildNotionHeaders(apiToken)(pinsNotion-Version: 2022-06-28on every call) andformatNotionError(error, fallback)(parsesFetchRetryError.bodyinto the canonicalNotion API error <status> (<code>): <message>string sounauthorized/restricted_resource/rate_limited/conflict_errorall surface cleanly).notion-create-page.tsPOST https://api.notion.com/v1/pages. Input takesparentDatabaseId,properties, optionalchildren/icon/cover. Responseid,url,created_time,last_edited_time,archived,propertiesmapped to camelCase output.notion-update-page.tsPATCH https://api.notion.com/v1/pages/${encodeURIComponent(pageId)}. Zod.refinerejects no-op updates (pageId alone); explicitproperties: {}is accepted as intent. Only keys the caller supplied are serialized into the body.notion-query-database.tsPOST https://api.notion.com/v1/databases/${encodeURIComponent(databaseId)}/query. Input: optionalfilter,sorts,startCursor, pluspageSize(default 100, bounded 1–100). Body converts camelCase → snake_case (start_cursor,page_size). Output converts snake_case → camelCase (hasMore,nextCursor).notion-append-blocks.tsPATCH https://api.notion.com/v1/blocks/${encodeURIComponent(blockId)}/children.childrenbounded 1–100 (Notion's per-request cap — we fail at the schema boundary instead of letting Notion reject with a 400). ReturnsappendedBlockIdsmapped fromresults[].id.index.ts__tests__/notion.test.tsModified
packages/nodes/src/integrations/index.ts— new// Notion integrationsexport block mirroring the Apify block.packages/core/src/types/node.ts—NodeCredentialsgainsnotion?: { apiToken: string }(same additive pattern used by every existing integration).Architectural alignment
Per
CLAUDE.md:fetchWithRetry's responsibility andExecutionConfig's responsibility at the engine level. Each executor isvalidate input → build body → fetch → map response → return.z.any()/as any. Opaque Notion shapes usez.record(z.string(), z.unknown()); page objects use.passthrough()to tolerate forward-compat API additions without opening the type system.defineNodeshape, samereturn { success, output } | { success: false, error }contract, samefetchWithRetryoptions (maxRetries: 3,backoffMs: 1000,timeoutMs: 30000).Test plan
60 vitest tests all passing. Full
@jam-nodes/nodessuite is 251 passing / 4 failing — the 4 failures are the pre-existingsrc/logic/__tests__/webhook-trigger.test.tszod v3/v4 compat errors onmainand are unchanged by this PR (0 regressions; baseline was 247 passing / 4 failing before this branch).notion/bearertype,Authorization: Bearer {{apiToken}}template{}, emptyapiToken,apiTokenwith\n,apiTokenwith\r; accepts valid tokenNotionBlockSchemais permissive,NotionSortSchemarejects missingdirection/ missingproperty|timestamp, accepts property+direction and timestamp+direction variantsbuildNotionHeaderssendsAuthorization,Content-Type: application/json,Notion-Version: 2022-06-28notion_create_pageinput schema: minimal payload, rejects emptyparentDatabaseIdnotion_create_pageexecutor: missing creds →notion.apiTokenerror, happy path asserts URL/method/all headers + mapped output,childrenforwarding,iconandcoverforwarding (new), properties content equality (not just.toBeDefined()), 400validation_error, 401unauthorized, 403restricted_resource, present-but-emptyproperties: {}notion_update_pageinput schema: rejects pageId-only no-op, accepts archived-only, accepts present-but-emptyproperties, rejects emptypageIdnotion_update_pageexecutor: missing creds, properties update (URL + method + body keys), archive with nopropertieskey in body, present-but-empty properties preserved in body,iconandcoverforwarding (new), URL-unsafepageIdencoded viaencodeURIComponent, 404object_not_found, 409conflict_erroron archived pagenotion_query_databaseinput schema: defaultpageSizeof 100, rejectspageSize: 0, rejectspageSize: 101, rejects emptydatabaseIdnotion_query_databaseexecutor: missing creds, happy path (results array +hasMore+nextCursor), filter / sorts /page_size(snake-case) /start_cursorforwarding, empty results, cursor boundary (empty results withhas_more: true),start_cursoromitted when not provided, passthrough on unknown page fields, 400validation_errornotion_append_blocksinput schema: rejects emptychildren, accepts single block, accepts exactly 100 children, rejects 101 children, rejects emptyblockIdnotion_append_blocksexecutor: missing creds, appends and returnsappendedBlockIds,aftercursor forwarded,afteromitted when not provided, URL encoding ofblockId, empty results → empty array, 429rate_limitedafter retry exhaustiontypeidentifiers, all fourcategory: 'integration',Notion-Versionheader sent on every operation via a cross-node sweepCommands used: