From 2e383d2d7e7ea4985dafe6167a2ee1d6f7b6e8f7 Mon Sep 17 00:00:00 2001 From: Mike Wu Date: Sat, 30 Mar 2024 06:01:50 +0900 Subject: [PATCH] feat: Generate README.md usage from dependencies (#52) * feat: generate README.md from dependencies * ci: Generate code * ci: Generate code * ci: Generate code * ci: Generate code * ci: Generate code * ci: Generate code * revert README.md * update to replace once * ci: Generate code * ci: Generate code * ci: Generate code * revert README.md * check generate loop * fix whitespace * ci: Generate code * move src/scripts -> scripts * move generate:readme -> above format * move generate-readme out of scripts * revert README.md * refactor to promises & specifying submodules * grab missing sections * replace types * skip type replacement * add back type replace * revert README.md * ci: Generate code * ci: Format code * ci: Generate code * ci: Format code * revert README.md * run generate * revert README.md * ignore whietspace * ci: Generate code * ci: Format code * remove current usage * Update generate-readme.ts Co-authored-by: Evan Sosenko * Update tsconfig.json Co-authored-by: Evan Sosenko * ignore genrate-readme in build * Update generate-readme.ts Co-authored-by: Evan Sosenko * use trim instead of regex * use trim instead of regex * revert readme * update ### Usage -> ## Usage * update ### Usage -> ## Usage * ci: Format code * ci: Generate code * ci: Format code * ci: Generate code * ci: Format code --------- Co-authored-by: Seam Bot Co-authored-by: Evan Sosenko --- .github/workflows/generate.yml | 2 + README.md | 381 ++++++++++++++++++++++++++++++++- generate-readme.ts | 71 ++++++ package.json | 1 + tsconfig.build.json | 2 +- tsconfig.json | 7 +- 6 files changed, 457 insertions(+), 7 deletions(-) create mode 100644 generate-readme.ts diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 6897f68..a4bc05c 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -33,6 +33,8 @@ jobs: install_dependencies: 'false' - name: Normalize package-lock.json run: npm install + - name: Generate README.md usage + run: npm run generate:readme - name: Commit uses: stefanzweifel/git-auto-commit-action@v5 with: diff --git a/README.md b/README.md index c0ffda5..502f42b 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,59 @@ $ npm install seam [npm]: https://www.npmjs.com/ -### Usage +## Usage + +First, create a webhook using the Seam API or Seam Console +and obtain a Seam webhook secret. + +_This example is for [Express], see the [Svix docs for more examples in specific frameworks](https://docs.svix.com/receiving/verifying-payloads/how)._ + +```js +import { SeamWebhook } from 'seam' +import express from 'express' +import bodyParser from 'body-parser' + +import { storeEvent } from './store-event.js' + +const app = express() + +const webhook = new SeamWebhook(process.env.SEAM_WEBHOOK_SECRET) + +app.post( + '/webhook', + bodyParser.raw({ type: 'application/json' }), + (req, res) => { + let data + try { + data = webhook.verify(payload, headers) + } catch { + return res.status(400).send() + } + + storeEvent(data, (err) => { + if (err != null) { + return res.status(500).send() + } + res.status(204).send() + }) + }, +) +``` + +[Express]: https://expressjs.com/ + +### Examples + +_These examples assume `SEAM_API_KEY` is set in your environment._ + +#### List devices + +```ts +import { Seam } from 'seam' + +const seam = new Seam() +const devices = await seam.devices.list() +``` #### Unlock a door @@ -58,13 +110,332 @@ const lock = await seam.locks.get({ name: 'Front Door' }) await seam.locks.unlockDoor({ device_id: lock.device_id }) ``` -#### Parse and validate a webhook +### Authentication Methods + +The SDK supports several authentication mechanisms. +Authentication may be configured by passing the corresponding +options directly to the `Seam` constructor, +or with the more ergonomic static factory methods. + +> Publishable Key authentication is not supported by the constructor +> and must be configured using `Seam.fromPublishableKey`. + +#### API Key + +An API key is scoped to a single workspace and should only be used on the server. +Obtain one from the Seam Console. ```ts -import { SeamWebhook } from 'seam' +// Set the `SEAM_API_KEY` environment variable +const seam = new Seam() + +// Pass as the first argument to the constructor +const seam = new Seam('your-api-key') + +// Pass as an option the constructor +const seam = new Seam({ apiKey: 'your-api-key' }) + +// Use the factory method +const seam = Seam.fromApiKey('your-api-key') +``` + +#### Client Session Token + +A Client Session Token is scoped to a client session and should only be used on the client. + +```ts +// Pass as an option the constructor +const seam = new Seam({ clientSessionToken: 'some-client-session-token' }) + +// Use the factory method +const seam = Seam.fromClientSessionToken('some-client-session-token') +``` + +The client session token may be updated using + +```ts +const seam = Seam.fromClientSessionToken('some-client-session-token') + +await seam.updateClientSessionToken('some-new-client-session-token') +``` + +#### Publishable Key + +A Publishable Key is used by the client to acquire Client Session Token for a workspace. +Obtain one from the Seam Console. + +Use the async factory method to return a client authenticated with a client session token: + +```ts +const seam = await Seam.fromPublishableKey( + 'your-publishable-key', + 'some-user-identifier-key', +) +``` + +This will get an existing client session matching the user identifier key, +or create a new empty client session. + +#### Personal Access Token + +A Personal Access Token is scoped to a Seam Console user. +Obtain one from the Seam Console. +A workspace id must be provided when using this method +and all requests will be scoped to that workspace. + +```ts +// Pass as an option the constructor + +const seam = new Seam({ + personalAccessToken: 'your-personal-access-token', + workspaceId: 'your-workspace-id', +}) + +// Use the factory method +const seam = Seam.fromPersonalAccessToken( + 'some-console-session-token', + 'your-workspace-id', +) +``` + +#### Console Session Token + +A Console Session Token is used by the Seam Console. +This authentication method is only used by internal Seam applications. +A workspace id must be provided when using this method +and all requests will be scoped to that workspace. + +```ts +// Pass as an option the constructor +const seam = new Seam({ + consoleSessionToken: 'some-console-session-token', + workspaceId: 'your-workspace-id', +}) + +// Use the factory method +const seam = Seam.fromConsoleSessionToken( + 'some-console-session-token', + 'your-workspace-id', +) +``` + +### Action Attempts + +Some asynchronous operations, e.g., unlocking a door, return an [action attempt]. +Seam tracks the progress of requested operation and updates the action attempt. + +To make working with action attempts more convenient for applications, +this library provides the `waitForActionAttempt` option. + +Pass the option per-request, + +```ts +await seam.locks.unlockDoor( + { device_id }, + { + waitForActionAttempt: true, + }, +) +``` + +or set the default option for the client: + +```ts +const seam = new Seam({ + apiKey: 'your-api-key', + waitForActionAttempt: true, +}) + +await seam.locks.unlockDoor({ device_id }) +``` + +If you have already have an action attempt id +and want to wait for it to resolve, simply use + +```ts +await seam.actionAttempts.get( + { action_attempt_id }, + { + waitForActionAttempt: true, + }, +) +``` + +Using the `waitForActionAttempt` option: + +- Polls the action attempt up to the `timeout` + at the `pollingInterval` (both in milliseconds). +- Resolves with a fresh copy of the successful action attempt. +- Rejects with a `SeamActionAttemptFailedError` if the action attempt is unsuccessful. +- Rejects with a `SeamActionAttemptTimeoutError` if the action attempt is still pending when the `timeout` is reached. +- Both errors expose an `actionAttempt` property. + +```ts +import { + Seam, + isSeamActionAttemptFailedError, + isSeamActionAttemptTimeoutError, +} from 'seam' + +const seam = new Seam('your-api-key') + +const [lock] = await seam.locks.list() + +if (lock == null) throw new Error('No locks in this workspace') + +try { + await seam.locks.unlockDoor( + { device_id: lock.device_id }, + { + waitForActionAttempt: { + pollingInterval: 1000, + timeout: 5000, + }, + }, + ) + console.log('Door unlocked') +} catch (err: unknown) { + if (isSeamActionAttemptFailedError(err)) { + console.log('Could not unlock the door') + return + } + + if (isSeamActionAttemptTimeoutError(err)) { + console.log('Door took too long to unlock') + return + } + + throw err +} +``` + +[action attempt]: https://docs.seam.co/latest/core-concepts/action-attempts + +### Interacting with Multiple Workspaces + +Some Seam API endpoints interact with multiple workspaces. +The `SeamMultiWorkspace` client is not bound to a specific workspace +and may use those endpoints with an appropriate authentication method. + +#### Personal Access Token + +A Personal Access Token is scoped to a Seam Console user. +Obtain one from the Seam Console. + +```ts +// Pass as an option the constructor +const seam = new SeamMultiWorkspace({ + personalAccessToken: 'your-personal-access-token', +}) + +// Use the factory method +const seam = SeamMultiWorkspace.fromPersonalAccessToken( + 'some-console-session-token', +) + +// List workspaces authorized for this Personal Access Token +const workspaces = await seam.workspaces.list() +``` + +#### Console Session Token + +A Console Session Token is used by the Seam Console. +This authentication method is only used by internal Seam applications. + +```ts +// Pass as an option the constructor +const seam = new SeamMultiWorkspace({ + consoleSessionToken: 'some-console-session-token', +}) + +// Use the factory method +const seam = SeamMultiWorkspace.fromConsoleSessionToken( + 'some-console-session-token', +) + +// List workspaces authorized for this Seam Console user +const workspaces = await seam.workspaces.list() +``` + +### Advanced Usage + +#### Additional Options + +In addition the various authentication options, +the constructor takes some advanced options that affect behavior. + +```ts +const seam = new Seam({ + apiKey: 'your-api-key', + endpoint: 'https://example.com', + axiosOptions: {}, + axiosRetryOptions: {}, +}) +``` + +When using the static factory methods, +these options may be passed in as the last argument. + +```ts +const seam = Seam.fromApiKey('some-api-key', { + endpoint: 'https://example.com', + axiosOptions: {}, + axiosRetryOptions: {}, +}) +``` + +#### Setting the endpoint + +Some contexts may need to override the API endpoint, +e.g., testing or proxy setups. +This option corresponds to the Axios `baseURL` setting. + +Either pass the `endpoint` option, or set the `SEAM_ENDPOINT` environment variable. + +#### Configuring the Axios Client + +The Axios client and retry behavior may be configured with custom initiation options +via [`axiosOptions`][axiosOptions] and [`axiosRetryOptions`][axiosRetryOptions]. +Options are deep merged with the default options. + +[axiosOptions]: https://axios-http.com/docs/config_defaults +[axiosRetryOptions]: https://github.com/softonic/axios-retry + +#### Using the Axios Client + +The Axios client is exposed and may be used or configured directly: + +```ts +import { Seam, DevicesListResponse } from 'seam' + +const seam = new Seam() + +seam.client.interceptors.response.use((response) => { + console.log(response) + return response +}) + +const devices = await seam.client.get('/devices/list') +``` + +#### Overriding the Client + +An Axios compatible client may be provided to create a `Seam` instance. +This API is used internally and is not directly supported. + +#### Inspecting the Request + +All client methods return an instance of `SeamRequest`. +Inspect the request before it is sent to the server by intentionally not awaiting the `SeamRequest`: + +```ts +const seam = new Seam('your-api-key') + +const request = seam.devices.list() + +console.log(`${request.method} ${request.url}`, JSON.stringify(request.body)) -const webhook = new SeamWebhook('webhook-secret') -const data = webhook.verify(payload, headers) +const devices = await request.execute() ``` ## Development and Testing diff --git a/generate-readme.ts b/generate-readme.ts new file mode 100644 index 0000000..ea0bc62 --- /dev/null +++ b/generate-readme.ts @@ -0,0 +1,71 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +const submodules = [ + path.join('@seamapi', 'webhook'), + path.join('@seamapi', 'http'), +] + +const sections = await Promise.all(submodules.map(readUsageSection)) +await writeReadmeUsage(sections.join('\n')) + +async function readUsageSection(modulePath: string): Promise { + const data = await fs.readFile( + path.join('node_modules', modulePath, 'README.md'), + { + encoding: 'utf-8', + }, + ) + + const regex = /#* Usage\s*([\s\S]*?(?=\n## \w))/ + const matches = regex.exec(data) + if (matches == null || matches.length !== 2) { + throw new Error('Missing [## Usage] section') + } + + const usage = matches[1] + if (usage == null) { + throw new Error('Invalid [## Usage] format') + } + + return usage.trimStart().trimEnd() +} + +async function writeReadmeUsage(content: string): Promise { + const projectReadme = await fs.readFile('README.md', { + encoding: 'utf-8', + }) + + const usageRegex = /## Usage\s*([\s\S]*?(?=\n## Development))/ + + const matches = usageRegex.exec(projectReadme) + + if (matches == null || matches.length !== 2 || matches[1] == null) { + throw new Error('Invalid README.md format') + } + + const updatedContent = content + .replaceAll('@seamapi/webhook', 'seam') + .replaceAll('@seamapi/http', 'seam') + .replaceAll('SeamHttp', 'Seam') + .replaceAll('seam/connect', 'seam') + + const currentUsageSection = matches[1] + + if ( + currentUsageSection + .replaceAll(/\s/g, '') + .includes(updatedContent.replaceAll(/\s/g, '')) // Remove all whitespace to ignore formatting changes + ) { + return + } + + const injected = `## Usage + +${updatedContent} +` + + const result = projectReadme.replace(usageRegex, injected) + + await fs.writeFile('README.md', result) +} diff --git a/package.json b/package.json index 31c2464..7b9d4da 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "lint": "eslint --ignore-path .gitignore .", "prelint": "prettier --check --ignore-path .gitignore .", "postversion": "git push --follow-tags", + "generate:readme": "tsx ./generate-readme.ts", "example": "tsx examples", "example:inspect": "tsx --inspect examples", "format": "eslint --ignore-path .gitignore --fix .", diff --git a/tsconfig.build.json b/tsconfig.build.json index 489019e..368ba03 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -11,5 +11,5 @@ }, "files": ["src/index.ts"], "include": ["src/**/*"], - "exclude": ["examples/**/*", "tsup.config.ts"] + "exclude": ["examples/**/*", "tsup.config.ts", "generate-readme.ts"] } diff --git a/tsconfig.json b/tsconfig.json index e78a1a9..71de92d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,5 +28,10 @@ } }, "files": ["src/index.ts"], - "include": ["src/**/*", "examples/**/*", "tsup.config.ts"] + "include": [ + "src/**/*", + "examples/**/*", + "tsup.config.ts", + "generate-readme.ts" + ] }