diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..06b7a63 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test Make app contracts + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm test diff --git a/README.md b/README.md index eb775c1..00916af 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This integration follows the same lightweight pattern as OpnForm's Activepieces ## Structure ``` -├── app.json # App metadata +├── base.iml.json # Shared API base URL, authorization, errors, and log sanitization ├── connection/ │ ├── parameters.json # Connection dialog fields (API key) │ └── communication.json # Auth validation (GET /open/workspaces) @@ -68,8 +68,8 @@ Docs: [developers.make.com/custom-apps-documentation](https://developers.make.co 1. Install the [Make Apps Editor](https://marketplace.visualstudio.com/items?itemName=Integromat.apps-sdk) VS Code extension 2. Generate a Make API key with `sdk-apps` scopes from your Make account -3. Connect the extension and sync the configs from this repo -4. Edit and test locally, then push changes to Make +3. Connect the extension to the existing Make app using the `Production` origin in `makecomapp.json` +4. Run `npm test`, then deploy the configs to the Testing origin before Production ## Related diff --git a/app.json b/app.json deleted file mode 100644 index ad6d93f..0000000 --- a/app.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "OpnForm", - "label": "OpnForm", - "description": "OpnForm is a free, open-source form builder. Connect your forms to Make scenarios to automate workflows triggered by form submissions.", - "version": "1.0.0", - "theme": "#3b82f6", - "categories": [ - "surveys-and-forms" - ], - "baseUrl": "https://api.opnform.com" -} diff --git a/base.iml.json b/base.iml.json new file mode 100644 index 0000000..e4a1754 --- /dev/null +++ b/base.iml.json @@ -0,0 +1,17 @@ +{ + "baseUrl": "{{ifempty(connection.baseUrl, 'https://api.opnform.com')}}", + "headers": { + "Authorization": "Bearer {{connection.apiKey}}", + "Accept": "application/json" + }, + "response": { + "error": { + "message": "[{{statusCode}}] {{ifempty(body.message, ifempty(body.error, 'Request failed'))}}" + } + }, + "log": { + "sanitize": [ + "request.headers.authorization" + ] + } +} diff --git a/icon/base.png b/icon/base.png index 8f8e0c2..6769fb6 100644 Binary files a/icon/base.png and b/icon/base.png differ diff --git a/makecomapp.json b/makecomapp.json index 11b0e6a..fc45966 100644 --- a/makecomapp.json +++ b/makecomapp.json @@ -1,7 +1,7 @@ { "fileVersion": 1, "generalCodeFiles": { - "base": "app.json", + "base": "base.iml.json", "common": null, "readme": "README.md", "groups": null @@ -107,9 +107,9 @@ { "label": "Production", "baseUrl": "https://eu1.make.com/api", - "appId": "opnform", + "appId": "opnform-65ly55", "appVersion": 1, "apikeyFile": ".secrets/apikey" } ] -} \ No newline at end of file +} diff --git a/modules/watchNewSubmissions/interface.json b/modules/watchNewSubmissions/interface.json index 0f73197..83205a5 100644 --- a/modules/watchNewSubmissions/interface.json +++ b/modules/watchNewSubmissions/interface.json @@ -19,6 +19,11 @@ "type": "uinteger", "label": "Submission ID" }, + { + "name": "edit_link", + "type": "url", + "label": "Edit Submission URL" + }, { "name": "data", "type": "collection", diff --git a/modules/watchNewSubmissions/samples.json b/modules/watchNewSubmissions/samples.json index 366f8f1..45132b8 100644 --- a/modules/watchNewSubmissions/samples.json +++ b/modules/watchNewSubmissions/samples.json @@ -3,6 +3,7 @@ "form_title": "Contact Form", "form_slug": "contact-form", "submission_id": 42, + "edit_link": "https://opnform.com/forms/contact-form?submission_id=example", "data": { "a5e98088-a4af-4efc-ad01-ce9ef8adb0c6": { "value": "Jane Doe", diff --git a/package.json b/package.json new file mode 100644 index 0000000..42a5d58 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "@opnform/make-integration", + "private": true, + "scripts": { + "test": "node --test tests/*.test.mjs" + } +} diff --git a/rpcs/listForms.json b/rpcs/listForms.json index a2d2aa4..5b20e1e 100644 --- a/rpcs/listForms.json +++ b/rpcs/listForms.json @@ -1,12 +1,6 @@ { - "url": "{{ifempty(connection.baseUrl, 'https://api.opnform.com')}}/open/workspaces/{{parameters.workspaceId}}/forms", + "url": "/open/workspaces/{{parameters.workspaceId}}/forms", "method": "GET", - "headers": { - "Authorization": "Bearer {{connection.apiKey}}" - }, - "qs": { - "page": "{{parameters.page}}" - }, "response": { "output": { "label": "{{item.title}}", @@ -16,7 +10,7 @@ }, "pagination": { "mergeWithParent": true, - "url": "{{body.next_page_url}}", - "condition": "{{body.next_page_url}}" + "url": "{{body.links.next}}", + "condition": "{{body.links.next}}" } } diff --git a/rpcs/listWorkspaces.json b/rpcs/listWorkspaces.json index 4287388..9fa5e84 100644 --- a/rpcs/listWorkspaces.json +++ b/rpcs/listWorkspaces.json @@ -1,8 +1,5 @@ { - "url": "{{ifempty(connection.baseUrl, 'https://api.opnform.com')}}/open/workspaces", - "headers": { - "Authorization": "Bearer {{connection.apiKey}}" - }, + "url": "/open/workspaces", "response": { "output": { "label": "{{item.name}}", diff --git a/tests/contracts.test.mjs b/tests/contracts.test.mjs new file mode 100644 index 0000000..945866f --- /dev/null +++ b/tests/contracts.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const readJson = async (path) => JSON.parse(await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) + +test('base inherits the self-hosted URL and protects the API key', async () => { + const base = await readJson('base.iml.json') + + assert.match(base.baseUrl, /connection\.baseUrl/) + assert.equal(base.headers.Authorization, 'Bearer {{connection.apiKey}}') + assert.deepEqual(base.log.sanitize, ['request.headers.authorization']) + assert.ok(base.response.error.message) +}) + +test('production origin targets the real Make app', async () => { + const manifest = await readJson('makecomapp.json') + const production = manifest.origins.find(({ label }) => label === 'Production') + + assert.equal(manifest.generalCodeFiles.base, 'base.iml.json') + assert.equal(production.appId, 'opnform-65ly55') +}) + +test('attached webhook persists and reuses the remote integration id', async () => { + const attach = await readJson('webhook/attach.json') + const detach = await readJson('webhook/detach.json') + + assert.equal(attach.response.data.externalHookId, '{{body.form_integration.id}}') + assert.match(detach.url, /{{webhook\.externalHookId}}/) + assert.doesNotMatch(detach.url, /webhook\.data/) +}) + +test('form RPC follows the Laravel resource pagination envelope', async () => { + const rpc = await readJson('rpcs/listForms.json') + const response = { + data: [{ id: 1, title: 'First form' }], + links: { next: 'https://api.opnform.com/open/workspaces/1/forms?page=2' }, + meta: { current_page: 1, last_page: 2 } + } + + assert.equal(rpc.response.iterate, '{{body.data}}') + assert.equal(rpc.pagination.url, '{{body.links.next}}') + assert.equal(rpc.pagination.condition, '{{body.links.next}}') + assert.equal(response.links.next.endsWith('page=2'), true) + assert.equal(response.next_page_url, undefined) + assert.equal(rpc.qs, undefined) +}) + +test('trigger interface exposes dynamic fields and optional edit link', async () => { + const output = await readJson('modules/watchNewSubmissions/interface.json') + const data = output.find(({ name }) => name === 'data') + const editLink = output.find(({ name }) => name === 'edit_link') + + assert.deepEqual(data.spec, []) + assert.equal(editLink.type, 'url') +}) + +test('Make requests use relative open API paths inherited from Base', async () => { + const paths = [ + 'rpcs/listWorkspaces.json', + 'rpcs/listForms.json', + 'webhook/attach.json', + 'webhook/detach.json' + ] + + for (const path of paths) { + const request = await readJson(path) + assert.match(request.url, /^\/open\//) + assert.doesNotMatch(request.url, /external\/make/) + } +}) diff --git a/webhook/attach.json b/webhook/attach.json index de363a1..4845a11 100644 --- a/webhook/attach.json +++ b/webhook/attach.json @@ -1,10 +1,8 @@ { - "url": "{{ifempty(connection.baseUrl, 'https://api.opnform.com')}}/open/forms/{{parameters.formId}}/integrations", + "url": "/open/forms/{{parameters.formId}}/integrations", "method": "POST", "headers": { - "Authorization": "Bearer {{connection.apiKey}}", - "Content-Type": "application/json", - "Accept": "application/json" + "Content-Type": "application/json" }, "body": { "integration_id": "make", diff --git a/webhook/detach.json b/webhook/detach.json index ff65491..abc2a47 100644 --- a/webhook/detach.json +++ b/webhook/detach.json @@ -1,8 +1,4 @@ { - "url": "{{ifempty(connection.baseUrl, 'https://api.opnform.com')}}/open/forms/{{parameters.formId}}/integrations/{{webhook.data.externalHookId}}", - "method": "DELETE", - "headers": { - "Authorization": "Bearer {{connection.apiKey}}", - "Accept": "application/json" - } + "url": "/open/forms/{{parameters.formId}}/integrations/{{webhook.externalHookId}}", + "method": "DELETE" }