diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index c2e60888c..7b483ac92 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -31,6 +31,7 @@ jobs: with: files: | docs/api-reference/** + mintlify-docs/api/** mintlify-docs/openapi.json mintlify-docs/docs.json mintlify-docs/mobile-sdks/** diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 7e7d8897b..e4824588e 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -55,14 +55,14 @@ jobs: run: gem install syntax_tree -v 6.2.0 - name: Normalize package-lock.json run: npm install - - name: Generate docs + - name: Generate GitBook API reference (codegen/smith.ts) uses: nick-fields/retry@v3 with: max_attempts: 3 retry_wait_seconds: 1 timeout_minutes: 2 command: npm run generate - - name: Generate Mintlify docs + - name: Generate Mintlify API reference (mintlify-codegen/smith.ts) uses: nick-fields/retry@v3 with: max_attempts: 3 diff --git a/.prettierignore b/.prettierignore index ca0eefdbb..cd4293982 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,6 +8,7 @@ # Prettier docs mintlify-docs +mintlify-codegen/source seam-sdk-playground gitbook-plugin *.hbs @@ -214,7 +215,7 @@ $RECYCLE.BIN/ .LSOverride # Icon must end with two \r -Icon +Icon # Thumbnails ._* diff --git a/mintlify-codegen/data/object-pages.yaml b/mintlify-codegen/data/object-pages.yaml new file mode 100644 index 000000000..ee9f2139d --- /dev/null +++ b/mintlify-codegen/data/object-pages.yaml @@ -0,0 +1,219 @@ +# Object-page metadata for the Mintlify API reference, keyed by API route path. +# Validated by mintlify-codegen/lib/object-page-metadata.ts. +# +# Every route with an object page has an entry; all object pages are generated. +# - title: the page's frontmatter title. Also the source of the noun used in +# generated events/errors page titles ("The Device Object" -> "Device"). +# - description: the page's frontmatter description. +# - endpoints: true renders an endpoint-listing page from the blueprint route +# (simulate/ and unmanaged/ routes). +# - namespace: true renders a resource-listing page for a namespace route. +# - resource_type: backs the page with a specific blueprint resource when the +# route has no resource of its own (e.g. /locks is a filtered view of the +# shared device resource). +# - include_groups / exclude_groups: property-group filter for the backing +# resource, following the codegen/data/paths.yaml idiom. +# - heading: overrides the section heading (default: 'The Object'). +# - intro: authored MDX rendered in place of the blueprint resource +# description (or above a namespace's resource list). + +/access_codes: + title: The Access Code Object + description: Learn how the access_code object represents a smart lock PIN code you can customize, track, and program across hundreds of supported keypad and pinpad locks. +/access_codes/simulate: + title: Access Code Simulations + description: Use the access code simulation endpoints to create unmanaged access codes in a sandbox workspace and test your access code migration flows safely. + endpoints: true +/access_codes/unmanaged: + title: Unmanaged Access Codes + description: Learn how the unmanaged_access_code object represents an existing smart lock PIN code that Seam tracks but does not manage until you convert it. +/access_grants: + title: The Access Grant Object + description: Learn how the access_grant object grants a user identity access to spaces, entrances, and devices through mobile keys, plastic cards, and PIN codes. +/access_grants/unmanaged: + title: Unmanaged + description: Use the unmanaged Access Grant endpoints to get, list, and update Access Grants that Seam tracks but does not manage (where is_managed is false). + endpoints: true +/access_methods: + title: The Access Method Object + description: Learn how the access_method object describes the modes of access for an Access Grant, such as PIN codes, plastic cards, and mobile keys. +/access_methods/unmanaged: + title: Unmanaged + description: Use the unmanaged access method endpoints to get and list access methods that Seam tracks but does not manage, usually filtered by Access Grant. + endpoints: true +/acs/access_groups: + title: The Access Group Object + description: Learn how the acs_access_group object defines the entrances a set of access system users can reach and, in some systems, their access schedule. +/acs/credentials: + title: The Credential Object + description: Learn how the acs_credential object represents the means—such as key cards, mobile keys, or PIN codes—by which an access system user gains entry. +/acs/encoders: + title: The Encoder Object + description: Learn how the acs_encoder object represents a hardware device that encodes credential data onto physical key cards within an access control system. +/acs/encoders/simulate: + title: Encoder Simulations + description: Use the encoder simulation endpoints to make the next credential encode or scan succeed or fail, so you can test card encoder flows in a sandbox. + endpoints: true +/acs/entrances: + title: The Entrance Object + description: Learn how the acs_entrance object represents a secured door, gate, or zone within an access control system, and how to list its credentials. +/acs: + title: Access Control Systems + description: Systems for managing and monitoring access to physical spaces + namespace: true + intro: | + Access control systems centralize access authorization for buildings, which means that you can use a single system to grant users access to one or more entrances. An access control system manages the following elements: + + - Who has access + - The entrances to which users have access + - The access schedule for each user and entrance + - The access mechanism—PIN codes, mobile keys, or plastic cards—for each access grant + + To grant access using the Seam access control system API, use the following basic process: + + 1. Create `acs_user`s. + 2. Configure access for these users. + For some access control systems, configure the allowed entrances and access schedule. For other systems, use `acs_access_group`s. For details, see the [system integration guide](/device-and-system-integration-guides#access-control-systems) for your access control system. + 3. Create `acs_credential`s, which are the digital means of granting access to users, such as PIN codes, mobile keys, and plastic (RFID) cards. + If you are developing a mobile app to provide access for your users, you can also use [Seam's iOS and Android SDKs](/capability-guides/mobile-access/mobile-device-sdks) that pair with the Seam API to create and manage mobile keys. + resources_intro: 'The Access Control Systems (ACS) namespace contains the following resources:' +/acs/systems: + title: The ACS System Object + description: Learn how the acs_system object represents an access control system in which you create users and credentials to grant them access at entrances. +/acs/users: + title: The ACS User Object + description: Learn how the acs_user object represents an individual—such as an employee or resident—who holds one or more credentials in an access control system. +/action_attempts: + title: The Action Attempt Object + description: Learn how the action_attempt object tracks an attempt to perform an action against a device, letting you monitor its status and final result. + resource_type: action_attempt + legacy: true +/client_sessions: + title: The Client Session Object + description: Learn how the client_session object restricts your users to their own devices using a custom user_identifier_key that scopes Seam API results. +/connect_webviews: + title: The Connect Webview Object + description: Learn how the connect_webview object embeds a client-side flow that walks your users through linking their device or system accounts to Seam. +/connected_accounts: + title: The Connected Account Object + description: Learn how the connected_account object represents an external third-party account your user has authorized Seam to access, such as an August account. +/connected_accounts/simulate: + title: Simulate + description: Use the connected account simulation endpoint to disconnect an account from Seam in a sandbox workspace and test how your app handles disconnections. + endpoints: true +/customers: + title: The Customer Object + description: Learn how the customer_portal object provides a hosted, customizable interface for managing device access that you can share or embed in your product. +/devices: + title: The Device Object + description: Learn how the device object represents a smart lock, thermostat, sensor, or other device connected to Seam, including its properties and events. +/devices/simulate: + title: Device Simulations + description: Use the device simulation endpoints to connect, disconnect, and remove sandbox devices so you can test how your app handles device state changes. + endpoints: true +/devices/unmanaged: + title: Unmanaged Devices + description: Learn how the unmanaged_device object exposes a limited set of properties and events for a device you cannot control until you convert it to managed. +/events: + title: The Event Object + description: Learn how the event object represents an occurrence in your workspace, such as a device action or status change, that you can list and react to. +/instant_keys: + title: The Instant Key Object + description: Learn how the instant_key object lets you issue a Bluetooth mobile key with one API call and share it by link, text, or email—no app install required. +/locks: + title: The Lock Object + description: Learn how the device object represents a smart lock connected to Seam, including its lock-specific properties, capabilities, errors, and events. + resource_type: device + legacy: true + include_groups: + - locks + - access_codes + heading: The device Object for Locks + intro: | + Represents a [lock](/low-level-apis/smart-locks). + + The Seam API enables you to control connected smart locks from a wide variety of manufacturers, including locks that provide online and offline [access code](/low-level-apis/smart-locks/access-codes) programming. + + Depending on the smart lock brand and model, actions that you can perform include remote unlock, remote lock, and programming access codes. You can also view a lock's properties, capabilities, and status. Further, you can monitor for unlock and lock events. + + See also [Webhooks](/developer-tools/webhooks). +/locks/simulate: + title: Lock Simulations + description: Use the lock simulation endpoints to mimic keypad code entry and manual locking on sandbox August devices so you can test your smart lock app. + endpoints: true +/noise_sensors/noise_thresholds: + title: Noise Thresholds + description: Learn how the noise_threshold object sets the limits of noise tolerated at a property, customizable for each hour of the day on a noise sensor. +/noise_sensors: + title: The Noise Sensor Object + description: Learn how the device object represents a noise sensor that measures sound levels, lets you configure thresholds, and emits disturbance events. + # TODO: consider a full device-view page (resource_type: device, + # include_groups: [noise_sensors]) like /locks and /thermostats; the migrated + # page only ever had an intro and an endpoint listing, so that shape is kept. + endpoints: true + heading: The device Object for Noise Sensors + intro: | + Represents a [noise sensor](/capability-guides/noise-sensors). + + Noise sensors are devices that measure that sound level in a given area. You can use noise sensors to monitor noise levels remotely and receive notifications when the noise volume is too loud. The Seam API enables you to configure the [noise thresholds](/capability-guides/noise-sensors/configure-noise-threshold-settings) of a noise sensor and receive events when a disturbance is detected. You can also [simulate triggering a noise threshold](/api/noise_sensors/simulate/trigger_noise_threshold). + + The Seam API represents a noise sensor as a `device` resource that includes both basic device properties and noise sensor-specific properties. +/noise_sensors/simulate: + title: Noise Sensor Simulations + description: Use the noise sensor simulation endpoint to trigger a noise threshold in a sandbox workspace so you can test how your app responds to disturbances. + endpoints: true +/phones: + title: The Phone Object + description: Learn how the phone object represents an app user mobile phone that holds mobile credentials, including its properties and lifecycle events. +/phones/simulate: + title: Phone Simulations + description: Use the phone simulation endpoint to create a simulated phone in a sandbox workspace so you can test mobile access flows for a user identity. + endpoints: true +/spaces: + title: The Space Object + description: Learn how the space object groups devices and entrances together so you can assign access to an entire space and grant access more efficiently. +/thermostats/daily_programs: + title: Daily Programs + description: Learn how the thermostat_daily_program object defines periods that each apply a climate preset at a starting time to automate a thermostat day. +/thermostats: + title: The Thermostat Object + description: Learn how the device object represents a thermostat connected to Seam, including its climate properties, capabilities, errors, warnings, and events. + resource_type: device + legacy: true + include_groups: + - thermostats + heading: The device Object for Thermostats + intro: | + Represents a [thermostat](/capability-guides/thermostats). + + You can use the Seam API to perform the following management and monitoring actions for thermostats: + + - Monitor current thermostat settings and readings. + - Configure [temperature thresholds](/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds). If the thermostat reports a temperature outside these thresholds, Seam automatically alerts you. + - Make immediate changes to [thermostat climate settings](/capability-guides/thermostats/configure-current-climate-settings), such as the HVAC mode and fan mode. + - [Create](/capability-guides/thermostats/creating-and-managing-climate-presets) and [schedule](/capability-guides/thermostats/creating-and-managing-thermostat-schedules) climate presets, including a fallback climate preset. + - Create daily and weekly [thermostat programs](/capability-guides/thermostats/creating-and-managing-thermostat-programs). + + The Seam API represents a thermostat as a `device` resource that includes both basic device properties and thermostat-specific properties. + + --- +/thermostats/schedules: + title: Thermostat Schedules + description: Learn how the thermostat_schedule object activates a climate preset on a thermostat at a starting time and deactivates it at a specified ending time. +/thermostats/simulate: + title: Thermostat Simulations + description: Use the thermostat simulation endpoints to mimic HVAC mode changes and reaching a target temperature on sandbox devices to test your thermostat app. + endpoints: true +/user_identities: + title: The User Identity Object + description: Learn how the user_identity object links a person to their application user account so you can manage their mobile credentials and access in Seam. +/user_identities/unmanaged: + title: Unmanaged + description: Use the unmanaged user identity endpoints to get, list, and update user identities that Seam tracks but does not manage (where is_managed is false). + endpoints: true +/webhooks: + title: The Webhook Object + description: Learn how the webhook object lets you receive event notifications at an endpoint URL you specify, for the set of event types you choose to subscribe to. +/workspaces: + title: The Workspace Object + description: Learn how the workspace object represents an isolated Seam environment that holds your devices, connected accounts, and resources, with sandbox support. diff --git a/mintlify-codegen/errors.ts b/mintlify-codegen/errors.ts deleted file mode 100644 index 442ad4b0c..000000000 --- a/mintlify-codegen/errors.ts +++ /dev/null @@ -1,722 +0,0 @@ -/* eslint-disable no-console */ -import { readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' - -import type { Blueprint, DiscriminatedListProperty } from '@seamapi/blueprint' - -import { formatType, indent, sampleValue } from './property-fields.js' - -/** - * Generate error and warning documentation for the API reference. - * - * Errors and warnings are enumerated states Seam reports on a resource (e.g. - * `device.errors[].error_code = "device_offline"`), each with a human-readable - * description. The blueprint models them as `discriminated_object` list - * properties on the resource, but the OpenAPI spec can only express the generic - * `errors`/`warnings` array shape — so the enumerated codes and their meanings - * were dropped when the API reference moved to OpenAPI-generated pages (the same - * gap events had before they were restored; see events.ts). - * - * This module restores them from `blueprint.resources[].properties` on every - * `npm run generate:mintlify`, producing one combined `errors.mdx` page per - * resource with an `## Errors` and an `## Warnings` section. Each section opens - * with the object shape (an example payload plus a properties accordion) and - * then lists every code with its meaning. A standalone page (rather than a - * section on the object page) renders full width and matches the layout the - * events pages use. - * - * `update-nav.ts` wires the generated pages into the sidebar after their object - * (and events) page. Link canonicalization (Phase G) runs afterward so - * `docs.seam.co` links in descriptions become canonical relative paths. - */ - -type Resource = Blueprint['resources'][number] -type Property = Resource['properties'][number] - -interface CodeEntry { - code: string - description: string -} - -interface CodeGroup { - // Group heading (e.g. "Locks"); null for the ungrouped variants. - name: string | null - entries: CodeEntry[] - // Set on inherited-error groups: the parent resource the errors come from and - // a link to its own errors page. Drives the explanatory callout. - inheritedFrom?: { - noun: string - href?: string - } -} - -function isDiscriminatedListProperty( - prop: Property | undefined, -): prop is Property & DiscriminatedListProperty { - return ( - prop != null && - 'itemFormat' in prop && - prop.itemFormat === 'discriminated_object' - ) -} - -/** - * Read the enumerated code (the discriminator enum's single value) from a - * variant's properties, e.g. `error_code = "device_offline"`. - */ -function variantCode( - variant: DiscriminatedListProperty['variants'][number], - discriminator: string, -): string | null { - const prop = variant.properties.find( - (p) => p.name === discriminator && p.format === 'enum', - ) as { values?: Array<{ name: string }> } | undefined - return prop?.values?.[0]?.name ?? null -} - -/** Map a list of variants to sorted code entries, dropping any without a - * discriminator code. */ -function variantsToEntries( - variants: DiscriminatedListProperty['variants'], - discriminator: string, -): CodeEntry[] { - return variants - .map((v) => { - const code = variantCode(v, discriminator) - return code == null - ? null - : { code, description: (v.description ?? '').trim() } - }) - .filter((e): e is CodeEntry => e != null) - .sort((a, b) => a.code.localeCompare(b.code)) -} - -/** - * Group a resource's `errors` or `warnings` property into ordered code groups: - * the ungrouped variants first (no heading), then each named variant group in - * blueprint order. Entries within a group are sorted by code. Returns an empty - * array when the property is absent or has no documented variants. - */ -function groupCodes(prop: Property | undefined): CodeGroup[] { - if (!isDiscriminatedListProperty(prop)) return [] - - const entriesFor = (key: string | null): CodeEntry[] => - variantsToEntries( - prop.variants.filter((v) => v.variantGroupKey === key), - prop.discriminator, - ) - - const groups: CodeGroup[] = [{ name: null, entries: entriesFor(null) }] - for (const group of prop.variantGroups) { - groups.push({ - name: group.name, - entries: entriesFor(group.variantGroupKey), - }) - } - return groups.filter((g) => g.entries.length > 0) -} - -// Resources whose inherited error groups are restricted to an allowlist of -// variant groups (in addition to the always-included ungrouped variants). An -// error's `variant.resourceType` names the resource it belongs to, so a resource -// inherits the errors whose type differs from its own (e.g. an access code -// inherits its lock's device errors). On the access code pages, only -// lock-related inherited errors are relevant; broader device categories like -// thermostats or noise sensors are not. -const INHERITED_ERROR_GROUP_ALLOWLIST: Record = { - access_code: ['locks'], - unmanaged_access_code: ['locks'], -} - -/** Convert a resource type (`connected_account`) into a display noun - * (`Connected Account`) for an inherited-error group heading. */ -function resourceTypeNoun(resourceType: string): string { - return resourceType - .split('_') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' ') -} - -/** - * Group a resource's `errors` property following the inheritance model: first - * the errors that belong to the resource itself (whose `variant.resourceType` - * matches), grouped by variant group exactly like `groupCodes`; then, for each - * parent resource whose errors this resource inherits (variants with a different - * `resourceType`), a single flat group per parent — variant groups are ignored, - * and the group is headed by the parent's noun. Inherited groups are ordered by - * parent resource type. When `inheritedGroupAllowlist` is set, inherited errors - * are limited to the ungrouped variants plus the listed variant groups. - */ -function groupErrorCodes( - prop: Property | undefined, - resourceType: string, - inheritedGroupAllowlist?: string[], - errorsHrefByResourceType?: Record, -): CodeGroup[] { - if (!isDiscriminatedListProperty(prop)) return [] - - // Unmanaged resources carry their managed counterpart's resource type on - // their variants (`unmanaged_access_code` errors are tagged `access_code`), so - // strip the `unmanaged_` prefix to identify the resource's own errors. A - // variant with no resource type is treated as the resource's own. - const ownResourceType = resourceType.replace(/^unmanaged_/, '') - const isOwn = ( - variant: DiscriminatedListProperty['variants'][number], - ): boolean => - variant.resourceType == null || variant.resourceType === ownResourceType - - // Errors matching the resource's own type, grouped by variant group. - const ownGroups = groupCodes({ - ...prop, - variants: prop.variants.filter(isOwn), - }) - - // Errors inherited from parent resources, one flat group per parent resource. - const parentResourceTypes = [ - ...new Set( - prop.variants - .map((v) => v.resourceType) - .filter((t): t is string => t != null && t !== ownResourceType), - ), - ].sort() - - const isAllowedGroup = (variantGroupKey: string | null): boolean => - inheritedGroupAllowlist == null || - variantGroupKey == null || - inheritedGroupAllowlist.includes(variantGroupKey) - - const inheritedGroups: CodeGroup[] = parentResourceTypes - .map((parentResourceType): CodeGroup => { - const variants = prop.variants.filter( - (v) => - v.resourceType === parentResourceType && - isAllowedGroup(v.variantGroupKey), - ) - const href = errorsHrefByResourceType?.[parentResourceType] - return { - name: resourceTypeNoun(parentResourceType), - entries: variantsToEntries(variants, prop.discriminator), - inheritedFrom: { - noun: resourceTypeNoun(parentResourceType), - ...(href == null ? {} : { href }), - }, - } - }) - .filter((g) => g.entries.length > 0) - - return [...ownGroups, ...inheritedGroups] -} - -/** - * Order an object's properties for display: the discriminator first, then - * `message` and `created_at`, then everything else alphabetically. Keeps the - * example payload and properties list readable and consistent across pages. - */ -function orderProperties(props: Property[], discriminator: string): Property[] { - const priority = [discriminator, 'message', 'created_at'] - const rank = (name: string): number => { - const i = priority.indexOf(name) - return i === -1 ? priority.length : i - } - return [...props].sort( - (a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name), - ) -} - -/** - * The union of properties across a discriminated list's variants, deduplicated - * by name (first occurrence wins). Variants share a core shape (`error_code` or - * `warning_code`, `message`, `created_at`) plus a few variant-specific flags, so - * the union documents every field a reader might encounter. - */ -function unionProperties(prop: DiscriminatedListProperty): Property[] { - const byName = new Map() - for (const variant of prop.variants) { - for (const p of variant.properties) { - if (!byName.has(p.name)) byName.set(p.name, p) - } - } - return orderProperties([...byName.values()], prop.discriminator) -} - -/** Strip Markdown link and inline-code syntax so a description reads as the - * plain-text string an API `message` field would actually contain - * (`[access grant](https://…)` -> `access grant`). */ -function toPlainText(md: string): string { - return md - .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') - .replace(/`([^`]+)`/g, '$1') - .trim() -} - -/** Build an example object from one variant: the concrete code and a plain-text - * message, with fixed sample values for the rest. */ -function buildExample( - variant: DiscriminatedListProperty['variants'][number], - discriminator: string, - code: string, -): Record { - const example: Record = {} - for (const p of orderProperties(variant.properties, discriminator)) { - if (p.name === discriminator) example[p.name] = code - else if (p.name === 'message') { - example[p.name] = - toPlainText(variant.description ?? '') || 'A human-readable message.' - } else example[p.name] = sampleValue(p) - } - return example -} - -/** Render one property of the object shape as a Mintlify ``. */ -function renderShapeProperty( - prop: Property, - discriminator: string, - kind: string, -): string { - const body = [ - (prop.description ?? '').trim() || `The ${prop.name.replace(/_/g, ' ')}.`, - ] - if (prop.name === discriminator) { - body.push('', `One of the ${kind} codes listed below.`) - } - return [ - ``, - indent(body.join('\n'), 2), - '', - ].join('\n') -} - -/** - * Render the object shape for a section: an example payload (built from the - * first variant) and an accordion documenting every property. Returns '' when - * the property is missing or has no variants. - */ -function renderObjectShape(prop: Property | undefined, kind: string): string { - if (!isDiscriminatedListProperty(prop)) return '' - const first = prop.variants[0] - if (first == null) return '' - - const code = variantCode(first, prop.discriminator) ?? '' - const json = JSON.stringify( - buildExample(first, prop.discriminator, code), - null, - 2, - ) - const fields = unionProperties(prop) - .map((p) => renderShapeProperty(p, prop.discriminator, kind)) - .join('\n\n') - const title = kind.charAt(0).toUpperCase() + kind.slice(1) - - return [ - `Each ${kind} is an object with the following shape:`, - '', - `\`\`\`json Example ${kind}`, - json, - '```', - '', - ``, - '', - fields, - '', - '', - ].join('\n') -} - -/** - * Render one code entry as a heading (so each code gets a linkable anchor) - * followed by its description and a divider that separates it from the next - * entry. `level` is the Markdown heading prefix (`###` for ungrouped codes, - * `####` for codes nested under a variant-group heading). - */ -function renderEntry(entry: CodeEntry, level: string): string { - const description = - entry.description || `Indicates the \`${entry.code}\` state.` - return [`${level} \`${entry.code}\``, '', description, '', '---'].join('\n') -} - -/** - * Render the callout that heads an inherited-error group, explaining that the - * codes belong to a parent resource and are surfaced here when set on it. - */ -function renderInheritedNote( - inheritedFrom: NonNullable, -): string { - const { noun, href } = inheritedFrom - const link = href == null ? noun : `[${noun}](${href})` - return [ - '', - ` These errors are inherited from the ${link} resource. When they are ` + - `set on the parent ${noun.toLowerCase()}, they are propagated to this ` + - `resource's errors list.`, - '', - ].join('\n') -} - -/** - * Render an `## Errors` or `## Warnings` section: the object shape followed by - * every code (as a linkable heading) with its meaning. Returns '' when there are - * no codes. `kind` is the singular noun (`error`/`warning`) used in prose. - */ -function renderSection( - title: string, - kind: string, - prop: Property | undefined, - groups: CodeGroup[], -): string { - if (groups.length === 0) return '' - const blocks: string[] = [`## ${title}`] - const shape = renderObjectShape(prop, kind) - if (shape) blocks.push(shape) - for (const group of groups) { - // Named variant groups get an `###` heading and nest their codes at `####`; - // ungrouped codes sit directly under the section at `###`. - const codeLevel = group.name != null ? '####' : '###' - if (group.name != null) blocks.push(`### ${group.name}`) - if (group.inheritedFrom != null) { - blocks.push(renderInheritedNote(group.inheritedFrom)) - } - for (const entry of group.entries) { - blocks.push(renderEntry(entry, codeLevel)) - } - } - return blocks.join('\n\n') -} - -/** The noun for a resource, from its object page title (`The Device Object` -> - * `Device`) or a humanized route path. */ -function resourceNoun(objectContent: string | null, routePath: string): string { - const match = objectContent?.match(/^title:\s*['"]?(.+?)['"]?\s*$/m) - const objectTitle = match?.[1] - const noun = objectTitle - ?.replace(/^The\s+/, '') - .replace(/\s+Object$/, '') - .trim() - if (noun) return noun - - return routePath - .slice(1) - .split('/') - .map((seg) => - seg - .split('_') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '), - ) - .join(' ') -} - -/** The `Errors`/`Warnings`/`Errors and Warnings` suffix for the given sections. */ -function kindSuffix(hasErrors: boolean, hasWarnings: boolean): string { - if (hasErrors && hasWarnings) return 'Errors and Warnings' - return hasErrors ? 'Errors' : 'Warnings' -} - -/** - * Render a note pointing readers to the errors/warnings shared by all devices. - * Device sub-category pages (locks/thermostats/phones) are subsets that omit the - * device-level codes (`device_offline`, `device_disconnected`, …) applying to - * every device, so a reader troubleshooting connectivity there needs a pointer - * to the aggregate device page. `kinds` is the lowercased sections phrase - * (`errors and warnings` / `errors` / `warnings`) so the prose matches the - * page's actual content. - */ -function renderCommonDeviceErrorsNote( - noun: string, - kinds: string, - route: string, -): string { - const phrase = kinds.toLowerCase() - return [ - '', - ` These are ${noun}-specific ${phrase}. For ${phrase} common to all devices, see [Device Errors and Warnings](${route}).`, - '', - ].join('\n') -} - -/** - * Render the full standalone errors/warnings page (frontmatter + sections). When - * `commonDeviceErrorsRoute` is set, a note linking to the shared device - * errors/warnings page is prepended — used on device sub-category pages. - */ -function renderPage( - noun: string, - errorSection: string, - warningSection: string, - commonDeviceErrorsRoute?: string, -): string { - const title = `${noun} ${kindSuffix(Boolean(errorSection), Boolean(warningSection))}` - const kinds = - errorSection && warningSection - ? 'Errors and warnings' - : errorSection - ? 'Errors' - : 'Warnings' - const description = `${kinds} that Seam reports on the ${noun} resource, each with its code and meaning.` - const frontmatter = [ - '---', - `title: '${title.replace(/'/g, "\\'")}'`, - `description: '${description.replace(/'/g, "\\'")}'`, - '---', - ].join('\n') - const note = commonDeviceErrorsRoute - ? renderCommonDeviceErrorsNote(noun, kinds, commonDeviceErrorsRoute) - : '' - const body = [note, errorSection, warningSection].filter(Boolean).join('\n\n') - return `${frontmatter}\n\n${body}\n` -} - -async function readFileOrNull(path: string): Promise { - try { - return await readFile(path, 'utf8') - } catch { - return null - } -} - -interface ErrorPageOptions { - // When set, prepend a note linking to the errors/warnings shared by all - // devices. Used only for device sub-category pages (locks/thermostats/phones), - // which are subsets that omit the device-level codes. - commonDeviceErrorsRoute?: string - // When set, errors are grouped by the inheritance model (own errors first, - // then a flat group per parent resource) keyed on this resource type. Used for - // real per-resource pages; omitted for device sub-category pages, which are - // already scoped to a single variant group. - inheritanceResourceType?: string - // Maps a resource type to its errors page href, used to link an inherited - // group's callout back to the parent resource's own errors page. - errorsHrefByResourceType?: Record -} - -/** - * Write one resource's `errors.mdx` (an `## Errors` and/or `## Warnings` - * section) and record its route in `routes`. No-ops when the resource has no - * documented codes or no object page yet (e.g. /acs/credentials). - */ -async function writeErrorPage( - docsDir: string, - routePath: string, - errorsProp: Property | undefined, - warningsProp: Property | undefined, - routes: string[], - options: ErrorPageOptions = {}, -): Promise { - // Defensive against a variant-group key ever coinciding with a real resource - // route: the resource loop writes first, so skip a route already emitted - // rather than clobbering it from the sub-category loop. - if (routes.includes(routePath)) { - console.log(` Errors page for ${routePath} already written, skipping`) - return - } - - const errorGroups = - options.inheritanceResourceType != null - ? groupErrorCodes( - errorsProp, - options.inheritanceResourceType, - INHERITED_ERROR_GROUP_ALLOWLIST[options.inheritanceResourceType], - options.errorsHrefByResourceType, - ) - : groupCodes(errorsProp) - const warningGroups = groupCodes(warningsProp) - if (errorGroups.length === 0 && warningGroups.length === 0) return - - const resourceDir = join(docsDir, 'api', routePath.slice(1)) - const objectContent = await readFileOrNull(join(resourceDir, 'object.mdx')) - if (objectContent == null) { - console.log(` No object page for errors on ${routePath}, skipping`) - return - } - - const noun = resourceNoun(objectContent, routePath) - const page = renderPage( - noun, - renderSection('Errors', 'error', errorsProp, errorGroups), - renderSection('Warnings', 'warning', warningsProp, warningGroups), - options.commonDeviceErrorsRoute, - ) - await writeFile(join(resourceDir, 'errors.mdx'), page) - routes.push(routePath) -} - -interface DeviceSubcategory { - // Route that carries the sub-category's dedicated errors page. - routePath: string - // Whether the sub-category shares the device-level errors/warnings documented - // on /api/devices/errors, and so should cross-link to them. The shared codes - // are physical-device connectivity/subscription errors (`device_offline`, - // `hub_disconnected`, `salto_ks_subscription_limit_exceeded`, …), relevant to - // locks and thermostats but not to phones (mobile devices used for - // credentials), so the phones page omits the cross-link. - linkCommonDeviceErrors: boolean -} - -// Some device sub-categories have their own docs route but no blueprint -// resource of their own — the Seam API models a lock, thermostat, or phone as a -// `device`. Their error/warning codes therefore live on the `device` resource -// under a variant group of the same key rather than on a resource of their own, -// so the per-resource loop never produces a page for them. This maps each such -// variant group to the route that should carry its dedicated page. -// -// The map is an explicit allowlist, not `'/' + groupKey`: groups like -// `access_codes`, `hardware`, and `provider_metadata` annotate the device page -// but have no standalone sub-category route. In particular /access_codes -// already documents the `access_code` resource's own, distinct errors — routing -// the device `access_codes` group there would clobber it. -const DEVICE_SUBCATEGORY_ERROR_ROUTES: Record = { - locks: { routePath: '/locks', linkCommonDeviceErrors: true }, - phones: { routePath: '/phones', linkCommonDeviceErrors: false }, - thermostats: { routePath: '/thermostats', linkCommonDeviceErrors: true }, -} - -// Route of the aggregate device errors/warnings page. Sub-categories that share -// the device-level codes link back to it (see writeErrorPage's -// `commonDeviceErrorsRoute` option). -const COMMON_DEVICE_ERRORS_ROUTE = '/api/devices/errors' - -/** - * Narrow a device `errors`/`warnings` property to a single variant group, - * flattening the kept variants to ungrouped (`variantGroupKey` cleared) so their - * codes render as top-level entries — a group heading would be redundant on a - * page already scoped to that sub-category. Returns undefined when the property - * is absent or the group has no variants, so the caller emits nothing. - */ -function variantGroupProp( - prop: Property | undefined, - groupKey: string, -): (Property & DiscriminatedListProperty) | undefined { - if (!isDiscriminatedListProperty(prop)) return undefined - const variants = prop.variants - .filter((v) => v.variantGroupKey === groupKey) - .map((v) => ({ ...v, variantGroupKey: null })) - if (variants.length === 0) return undefined - return { ...prop, variants, variantGroups: [] } -} - -/** - * Warn when a device errors/warnings variant group looks like it should have its - * own sub-category errors page but is missing from DEVICE_SUBCATEGORY_ERROR_ROUTES. - * - * That allowlist is maintained by hand, so a newly added device sub-category - * (its own object + events pages plus an error/warning variant group) would - * silently produce no errors page until someone edits the map. This surfaces - * that inconsistency at generate time. - * - * A group is flagged only when it (a) has documented codes, (b) has both an - * `object.mdx` and an `events.mdx` under `/api/` — the shape a real - * sub-category route has — and (c) is neither already mapped nor itself a - * documented blueprint resource. Condition (c) keeps real resources - * (`access_codes`, which owns its own distinct errors) and annotation-only - * groups (`hardware`, `provider_metadata` — no such route) from tripping it. - */ -async function warnUnmappedDeviceSubcategoryGroups( - docsDir: string, - errorsProp: Property | undefined, - warningsProp: Property | undefined, - documentedResourceRoutes: Set, -): Promise { - const groupKeys = new Set() - for (const prop of [errorsProp, warningsProp]) { - if (!isDiscriminatedListProperty(prop)) continue - for (const group of prop.variantGroups) groupKeys.add(group.variantGroupKey) - } - - for (const groupKey of groupKeys) { - if (groupKey in DEVICE_SUBCATEGORY_ERROR_ROUTES) continue - if (documentedResourceRoutes.has(`/${groupKey}`)) continue - - const hasCodes = - groupCodes(variantGroupProp(errorsProp, groupKey)).length > 0 || - groupCodes(variantGroupProp(warningsProp, groupKey)).length > 0 - if (!hasCodes) continue - - const resourceDir = join(docsDir, 'api', groupKey) - const hasObject = - (await readFileOrNull(join(resourceDir, 'object.mdx'))) != null - const hasEvents = - (await readFileOrNull(join(resourceDir, 'events.mdx'))) != null - if (!hasObject || !hasEvents) continue - - console.log( - ` WARNING: device errors/warnings variant group "${groupKey}" has codes ` + - `and an object+events page (/api/${groupKey}) but no dedicated errors ` + - `page. Add it to DEVICE_SUBCATEGORY_ERROR_ROUTES in mintlify-codegen/errors.ts.`, - ) - } -} - -/** - * Generate the per-resource `errors.mdx` pages. Returns the route paths that - * received a page (e.g. `/devices`) so the caller can wire them into the - * navigation. - */ -export async function updateErrorPages( - blueprint: Blueprint, - docsDir: string, -): Promise { - const routes: string[] = [] - - // Every documented resource's errors page lives at `/api//errors`, - // so an inherited-error group can link back to the parent it came from. - const errorsHrefByResourceType: Record = {} - for (const resource of blueprint.resources) { - if (resource.isUndocumented) continue - errorsHrefByResourceType[resource.resourceType] = - `/api${resource.routePath}/errors` - } - - for (const resource of blueprint.resources) { - if (resource.isUndocumented) continue - await writeErrorPage( - docsDir, - resource.routePath, - resource.properties.find((p) => p.name === 'errors'), - resource.properties.find((p) => p.name === 'warnings'), - routes, - { - inheritanceResourceType: resource.resourceType, - errorsHrefByResourceType, - }, - ) - } - - // Pages for device sub-categories (locks, thermostats, …) that have their own - // docs route but no resource of their own: source each from the matching - // variant group on the device resource. - const device = blueprint.resources.find((r) => r.resourceType === 'device') - if (device != null) { - const errorsProp = device.properties.find((p) => p.name === 'errors') - const warningsProp = device.properties.find((p) => p.name === 'warnings') - for (const [ - groupKey, - { routePath, linkCommonDeviceErrors }, - ] of Object.entries(DEVICE_SUBCATEGORY_ERROR_ROUTES)) { - await writeErrorPage( - docsDir, - routePath, - variantGroupProp(errorsProp, groupKey), - variantGroupProp(warningsProp, groupKey), - routes, - linkCommonDeviceErrors - ? { commonDeviceErrorsRoute: COMMON_DEVICE_ERRORS_ROUTE } - : {}, - ) - } - - // Flag any device sub-category that has its own object+events pages and - // error codes but was never added to the allowlist above. - const documentedResourceRoutes = new Set( - blueprint.resources - .filter((r) => !r.isUndocumented) - .map((r) => r.routePath), - ) - await warnUnmappedDeviceSubcategoryGroups( - docsDir, - errorsProp, - warningsProp, - documentedResourceRoutes, - ) - } - - return routes -} diff --git a/mintlify-codegen/events.ts b/mintlify-codegen/events.ts deleted file mode 100644 index c14fec8b4..000000000 --- a/mintlify-codegen/events.ts +++ /dev/null @@ -1,383 +0,0 @@ -/* eslint-disable no-console */ -import { readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' - -import type { Blueprint } from '@seamapi/blueprint' - -import { formatType, hasEnumValues, indent } from './property-fields.js' - -/** - * Generate event documentation for the API reference. - * - * Events are webhook payloads, not endpoints, so they have no OpenAPI - * representation and were dropped when the API reference moved to - * OpenAPI-generated pages. This module restores them from `blueprint.events` - * (which the pipeline already builds but never reads) so they stay in sync with - * `@seamapi/types` on every `npm run generate:mintlify`. It produces: - * - * 1. A dedicated `events.mdx` page per resource that emits events, each event - * documented with an example webhook payload and its properties. A - * standalone page (rather than a section on the object page) renders full - * width instead of squeezed into the object page's two-column layout, and - * gives each event a stable, linkable heading anchor. - * 2. The full `event_type` enum on the Event object page. - * - * `update-nav.ts` wires the generated pages into the sidebar next to their - * object page. Link canonicalization (Phase G) runs afterward so `docs.seam.co` - * links in event descriptions become canonical relative paths. - */ - -// Sentinel markers previously delimited an events section embedded in the -// object pages. They are matched (never emitted) only to strip that section -// from object pages generated by earlier versions of this module. -const EVENTS_START = '{/* SEAM-GENERATED-EVENTS:START */}' -const EVENTS_END = '{/* SEAM-GENERATED-EVENTS:END */}' - -// Route path of the Event object page itself. Events do not emit events, so -// this path never appears in `blueprint.events`; it is handled separately to -// receive the full `event_type` enum. -const EVENT_OBJECT_ROUTE = '/events' - -type EventResource = Blueprint['events'][number] -type EventProperty = EventResource['properties'][number] - -/** - * Object-format properties (e.g. the `from`/`to` of a `*.*_changed` event) - * carry their own typed `properties` array. Free-form records - * (`*_custom_metadata`) have an empty array; those still render as `{}`. - */ -function hasNestedProperties( - prop: EventProperty, -): prop is EventProperty & { properties: EventProperty[] } { - return ( - 'properties' in prop && - Array.isArray((prop as { properties?: unknown }).properties) && - (prop as { properties: unknown[] }).properties.length > 0 - ) -} - -// Illustrative values for well-known fields that the type system types as -// plain strings, so their `from`/`to` payloads read realistically instead of -// showing `""`. `*_at` string fields are handled as datetimes below. -const SAMPLE_STRING_VALUES: Record = { - code: '1234', - name: 'My Access Code', -} - -/** - * Build an illustrative sample value for an event property from its format. - * Values are fixed (never random) so generated payloads are stable across - * runs; this mirrors the value conventions in load-data.ts. This event-specific - * variant recurses into a `*_changed` event's typed `from`/`to` payloads; the - * shared basic version used by the object and error pages lives in - * property-fields.ts. - */ -function sampleValue(prop: EventProperty): unknown { - if (hasEnumValues(prop) && prop.values.length > 0) { - return prop.values[0]?.name ?? '' - } - switch (prop.format) { - case 'id': - return '00000000-0000-0000-0000-000000000000' - case 'datetime': - return '2025-01-01T00:00:00.000Z' - case 'boolean': - return true - case 'number': - return 0 - case 'list': - return [] - case 'object': - case 'record': { - // Typed objects (a `*_changed` event's `from`/`to`) expose their own - // `properties`; recurse to show their shape. Free-form records have none - // and stay `{}`. - if (!hasNestedProperties(prop)) return {} - const nested: Record = {} - for (const child of prop.properties) { - if (child.isUndocumented) continue - nested[child.name] = sampleValue(child) - } - return nested - } - case 'string': - // Some datetime fields (e.g. a time frame's `starts_at`/`ends_at`) are - // typed as plain strings; render them like the datetime fields above. - if (/_at$/.test(prop.name)) return '2025-01-01T00:00:00.000Z' - return SAMPLE_STRING_VALUES[prop.name] ?? '' - default: - return null - } -} - -/** - * Synthesize an example webhook payload for an event so readers can see its - * shape without triggering the event. `event_type` gets the concrete type and - * `event_description` echoes the event's description (matching the real - * payload); every other field gets a type-appropriate placeholder. - */ -function buildEventSample(event: EventResource): Record { - const sample: Record = {} - for (const prop of event.properties) { - if (prop.isUndocumented) continue - if (prop.name === 'event_type') { - sample[prop.name] = event.eventType - } else if (prop.name === 'event_description') { - sample[prop.name] = event.description.trim() - } else { - sample[prop.name] = sampleValue(prop) - } - } - return sample -} - -/** - * Render an event's example payload wrapped in a ``, which - * renders as a titled "Example webhook payload" card. Note this does not pin to - * a right-hand column: Mintlify only pins a single ResponseExample per page (as - * on the object pages), so on an events page — which has one per event — the - * cards render inline in the content column. - */ -function renderEventSample(event: EventResource): string { - const json = JSON.stringify(buildEventSample(event), null, 2) - return [ - '', - '', - '```json Example webhook payload', - json, - '```', - '', - '', - ].join('\n') -} - -/** Render a single event property as a Mintlify ``. */ -function renderEventProperty(prop: EventProperty): string { - const body: string[] = [] - - // `event_type` is a single-value enum: show the concrete value. - if ( - prop.name === 'event_type' && - hasEnumValues(prop) && - prop.values.length === 1 && - prop.values[0] != null - ) { - body.push(`Value: \`${prop.values[0].name}\``) - } else { - const description = (prop.description ?? '').trim() - body.push(description || `The ${prop.name.replace(/_/g, ' ')}.`) - - if (hasEnumValues(prop) && prop.values.length > 0) { - const values = prop.values.map((value) => `\`${value.name}\``).join(', ') - body.push('', `Possible values: ${values}`) - } - } - - // Typed objects (a `*_changed` event's `from`/`to`) document their child - // fields in a nested ``, matching the object pages. - if (hasNestedProperties(prop)) { - const children = prop.properties - .filter((child) => !child.isUndocumented) - .map((child) => indent(renderEventProperty(child), 4)) - if (children.length > 0) { - body.push( - '', - '', - ...children, - '', - ) - } - } - - return [ - ``, - indent(body.join('\n'), 2), - '', - ].join('\n') -} - -/** - * Render one event: its type and description, an example payload showing its - * shape, then a collapsible `` of its properties. Mintlify strips - * raw `
` elements, so the collapsible uses the native ``. - */ -function renderEvent(event: EventResource): string { - const properties = event.properties - .filter((prop) => !prop.isUndocumented) - .map(renderEventProperty) - .join('\n\n') - - return [ - `## \`${event.eventType}\``, - '', - event.description.trim(), - '', - renderEventSample(event), - '', - '', - '', - properties, - '', - '', - ].join('\n') -} - -/** - * Derive the events page title from the sibling object page's title - * (`The Access Grant Object` -> `Access Grant Events`), falling back to a - * humanized route path when the object title is missing or unexpected. - */ -function deriveEventsTitle( - objectContent: string | null, - routePath: string, -): string { - const match = objectContent?.match(/^title:\s*['"]?(.+?)['"]?\s*$/m) - const objectTitle = match?.[1] - const noun = objectTitle - ?.replace(/^The\s+/, '') - .replace(/\s+Object$/, '') - .trim() - if (noun) return `${noun} Events` - - const humanized = routePath - .slice(1) - .split('/') - .map((seg) => - seg - .split('_') - .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' '), - ) - .join(' ') - return `${humanized} Events` -} - -/** Render the full standalone events page (frontmatter + one section per event). */ -function renderEventsPage(events: EventResource[], title: string): string { - const description = `Webhook events that Seam emits for the ${title.replace(/\s+Events$/, '')} resource, with example payloads and properties.` - const frontmatter = [ - '---', - `title: '${title.replace(/'/g, "\\'")}'`, - `description: '${description.replace(/'/g, "\\'")}'`, - '---', - ].join('\n') - const body = events.map(renderEvent).join('\n\n') - return `${frontmatter}\n\n${body}\n` -} - -/** - * Remove an events section embedded in an object page by an earlier version of - * this module (delimited by the sentinel markers). Returns the cleaned content, - * or null when there is nothing to strip. - */ -function stripEmbeddedEventsSection(content: string): string | null { - const startIdx = content.indexOf(EVENTS_START) - if (startIdx === -1) return null - const endIdx = content.indexOf(EVENTS_END, startIdx) - if (endIdx === -1) return null - const after = endIdx + EVENTS_END.length - const cleaned = `${content.slice(0, startIdx).replace(/\s+$/, '')}\n${content.slice(after).replace(/^\s+/, '\n')}` - return cleaned.replace(/\n{3,}/g, '\n\n') -} - -/** - * Replace the `event_type` ResponseField on the Event object page with one that - * lists every event type as enum values. Idempotent: matches the field whether - * or not it already carries the enum block. - */ -function injectEventTypeEnum(content: string, eventTypes: string[]): string { - const field = [ - '', - ' The event type.', - '', - ' ', - ...eventTypes.map((type) => ` - \`${type}\``), - ' ', - '', - ].join('\n') - - return content.replace( - /]*>[\s\S]*?<\/ResponseField>/, - field, - ) -} - -/** Group events by the route path of the resource that emits them. */ -function groupEventsByRoutePath( - events: EventResource[], -): Map { - const byRoute = new Map() - for (const event of events) { - const group = byRoute.get(event.routePath) ?? [] - group.push(event) - byRoute.set(event.routePath, group) - } - return byRoute -} - -async function readFileOrNull(path: string): Promise { - try { - return await readFile(path, 'utf8') - } catch { - return null - } -} - -/** - * Generate the per-resource `events.mdx` pages and the Event object page enum. - * Returns the route paths that received an events page (e.g. `/access_grants`) - * so the caller can wire them into the navigation. - */ -export async function updateEventPages( - blueprint: Blueprint, - docsDir: string, -): Promise { - const events = (blueprint.events ?? []).filter( - (event) => !event.isUndocumented, - ) - const eventPageRoutes: string[] = [] - - // 1. A dedicated events page per resource, alongside its object page. - for (const [routePath, routeEvents] of groupEventsByRoutePath(events)) { - const resourceDir = join(docsDir, 'api', routePath.slice(1)) - const objectContent = await readFileOrNull(join(resourceDir, 'object.mdx')) - if (objectContent == null) { - // Some event route paths have no object page (e.g. - // /user_identities/enrollment_automations). Skip until a page exists. - console.log(` No object page for events on ${routePath}, skipping`) - continue - } - - // Clean up any events section embedded in the object page by an earlier - // version of this module (events now live on their own page). - const stripped = stripEmbeddedEventsSection(objectContent) - if (stripped != null) { - await writeFile(join(resourceDir, 'object.mdx'), stripped) - } - - const title = deriveEventsTitle(objectContent, routePath) - await writeFile( - join(resourceDir, 'events.mdx'), - renderEventsPage(routeEvents, title), - ) - eventPageRoutes.push(routePath) - } - - // 2. Full `event_type` enum on the Event object page. - const eventTypes = [...new Set(events.map((event) => event.eventType))] - const eventObjectPath = join( - docsDir, - 'api', - EVENT_OBJECT_ROUTE.slice(1), - 'object.mdx', - ) - const eventObjectContent = await readFileOrNull(eventObjectPath) - if (eventObjectContent == null) { - console.log(` No Event object page at ${eventObjectPath}, skipping enum`) - } else { - const next = injectEventTypeEnum(eventObjectContent, eventTypes) - if (next !== eventObjectContent) await writeFile(eventObjectPath, next) - } - - return eventPageRoutes -} diff --git a/mintlify-codegen/generate.ts b/mintlify-codegen/generate.ts deleted file mode 100644 index 23901fbdf..000000000 --- a/mintlify-codegen/generate.ts +++ /dev/null @@ -1,414 +0,0 @@ -/* eslint-disable no-console */ -import { readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { env } from 'node:process' - -import type { Blueprint } from '@seamapi/blueprint' - -import { canonicalizeGeneratedLinks } from './canonicalize-links.js' -import { updateErrorPages } from './errors.js' -import { updateEventPages } from './events.js' -import { getRawOpenApiSpec, loadBlueprint } from './load-data.js' -import { transformSpec } from './transform-spec.js' -import { - insertErrorPagesIntoNav, - insertEventsPagesIntoNav, - updateDocsJson, -} from './update-nav.js' - -const skipCodeFormat = env['SKIP_CODE_FORMAT'] != null - -const hiddenProperties: Record> = {} - -console.log('Mintlify OpenAPI codegen starting...') -if (skipCodeFormat) { - console.log(' SKIP_CODE_FORMAT is set — skipping code formatting') -} - -// Phase A+B: Load inputs and create blueprint -console.log('Loading blueprint...') -const { blueprint, pathMetadata } = await loadBlueprint(skipCodeFormat) -const totalEndpoints = blueprint.routes.reduce( - (sum, r) => sum + r.endpoints.length, - 0, -) -console.log( - ` Blueprint loaded: ${totalEndpoints} endpoints across ${blueprint.routes.length} routes`, -) - -// Phase C: Get and transform the raw OpenAPI spec -console.log('Transforming OpenAPI spec...') -const rawSpec = getRawOpenApiSpec() -const { spec, stats } = transformSpec(rawSpec, blueprint, pathMetadata) - -// Phase D: Write the enriched OpenAPI spec -const outputDir = join(import.meta.dirname, '..', 'mintlify-docs') -const outputPath = join(outputDir, 'openapi.json') - -const orderedSpec = { - openapi: spec.openapi, - info: spec.info, - servers: spec.servers, - components: spec.components, - ...(spec.tags ? { tags: spec.tags } : {}), - paths: spec.paths, -} -await writeFile(outputPath, JSON.stringify(orderedSpec, null, 2)) - -const size = JSON.stringify(orderedSpec).length -console.log( - ` Wrote openapi.json: ${stats.totalEndpoints} endpoints (${(size / 1024).toFixed(0)}KB)`, -) - -// Phase E: Update docs.json navigation -console.log('Updating docs.json navigation...') -const specPaths = new Set(Object.keys(spec.paths)) -await updateDocsJson(specPaths) - -// Phase F: Update resource sample tabs in object.mdx files -console.log('Updating object page resource samples...') -const updatedObjects = await updateObjectPages(blueprint, outputDir) -if (updatedObjects.length > 0) { - console.log( - ` Updated ${updatedObjects.length} object pages: ${updatedObjects.join(', ')}`, - ) -} - -// Phase F.5: Generate event documentation from blueprint.events — a dedicated -// events page per resource plus the `event_type` enum on the Event object page. -// Runs after object pages (so it can strip any legacy embedded events section) -// and before link canonicalization (so event-description links are rewritten). -console.log('Updating event documentation...') -const updatedEvents = await updateEventPages(blueprint, outputDir) -if (updatedEvents.length > 0) { - console.log( - ` Generated events pages for ${updatedEvents.length} resources: ${updatedEvents.join(', ')}`, - ) -} - -// Phase F.6: Wire the generated events pages into the sidebar next to their -// object page. Runs after nav (Phase E) so its transforms don't strip them. -await insertEventsPagesIntoNav(updatedEvents) - -// Phase F.7: Generate error/warning documentation from the errors/warnings -// properties on each resource — one combined page per resource. Like events, -// these are blueprint-only (the OpenAPI spec can't express the enumerated -// codes) and run before link canonicalization so their description links are -// rewritten. Nav wiring runs after events so the sidebar reads -// object -> events -> errors. -console.log('Updating error and warning documentation...') -const updatedErrors = await updateErrorPages(blueprint, outputDir) -if (updatedErrors.length > 0) { - console.log( - ` Generated errors pages for ${updatedErrors.length} resources: ${updatedErrors.join(', ')}`, - ) -} -await insertErrorPagesIntoNav(updatedErrors) - -// Phase G: canonicalize docs links in generated output. Guards against two -// classes of upstream @seamapi/types regression: -// 1. legacy `/latest` reappearing in `docs.seam.co/latest/...` links -// (the site serves at the root; a `/latest/:path*` redirect handles old -// inbound URLs). See DOC-206 / DOC-199. -// 2. links that target a path which docs.json redirects elsewhere — these -// still resolve in the browser but cost an extra hop and dodge anchor -// validation. We rewrite them to their final canonical destination so -// generated content links directly. See DOC-210. -// This is self-healing: every `npm run generate` re-applies it, so source -// descriptions can lag without leaving stale links in the published docs. -console.log('Canonicalizing docs links in generated output...') -const sanitizedCount = await canonicalizeGeneratedLinks(outputDir) -console.log(` Canonicalized links in ${sanitizedCount} generated file(s)`) - -console.log(`\nDone!`) -console.log(` Removed undocumented paths: ${stats.removedPaths}`) -console.log(` Total documented endpoints: ${stats.totalEndpoints}`) -console.log(` With code samples: ${stats.withCodeSamples}`) -console.log(` With scoped action_attempts: ${stats.withActionAttempts}`) -console.log( - ` Without code samples: ${stats.withoutCodeSamples.length} endpoints`, -) -if (stats.withoutCodeSamples.length > 0) { - console.log( - ` Missing samples: ${stats.withoutCodeSamples.slice(0, 10).join(', ')}${stats.withoutCodeSamples.length > 10 ? `... and ${stats.withoutCodeSamples.length - 10} more` : ''}`, - ) -} - -function renderCodeGroup( - samples: Array<{ - title: string - description: string - properties: Record - }>, -): string { - const blocks = samples.map((s) => { - const json = JSON.stringify(s.properties, null, 2) - return `\`\`\`json ${s.title}\n${json}\n\`\`\`` - }) - return `\n\n${blocks.join('\n\n')}\n\n` -} - -interface BlueprintProperty { - name: string - description: string - format: string - jsonType: string - isDeprecated: boolean - isUndocumented: boolean - properties?: BlueprintProperty[] - propertyGroups?: Array<{ name: string; propertyGroupKey: string }> - propertyGroupKey?: string | null -} - -function shouldHide(resourceType: string, prop: BlueprintProperty): boolean { - if (prop.isUndocumented) return true - return hiddenProperties[resourceType]?.has(prop.name) ?? false -} - -function renderResponseField( - prop: BlueprintProperty, - resourceType: string, -): string { - const type = formatPropertyType(prop.format, prop.jsonType) - const attrs = [`name="${prop.name}"`, `type="${type}"`] - if (prop.isDeprecated) attrs.push('deprecated') - const desc = prop.description || `The ${prop.name.replace(/_/g, ' ')}.` - - if ( - prop.format === 'object' && - prop.properties && - prop.properties.length > 0 - ) { - const children = prop.properties - .filter((c) => !shouldHide(resourceType, c)) - .map((c) => renderResponseField(c, resourceType)) - if (children.length > 0) { - return [ - ``, - ` ${desc}`, - ` `, - ...children.map((c) => indent(c, 4)), - ` `, - ``, - ].join('\n') - } - } - - return `\n ${desc}\n` -} - -function indent(text: string, spaces: number): string { - const pad = ' '.repeat(spaces) - return text - .split('\n') - .map((line) => (line.trim() ? pad + line : line)) - .join('\n') -} - -function renderProperties( - properties: BlueprintProperty[], - resourceType: string, -): string { - const ungrouped = properties.filter( - (p) => !shouldHide(resourceType, p) && !p.propertyGroupKey, - ) - const fields = ungrouped.map((p) => renderResponseField(p, resourceType)) - return fields.join('\n\n') -} - -function renderGroupedProperties( - properties: BlueprintProperty[], - resourceType: string, -): string { - const groups = new Map() - for (const p of properties) { - if (shouldHide(resourceType, p) || !p.propertyGroupKey) continue - const existing = groups.get(p.propertyGroupKey) - if (existing) { - existing.props.push(p) - } else { - groups.set(p.propertyGroupKey, { name: p.propertyGroupKey, props: [p] }) - } - } - - if (groups.size === 0) return '' - - const sections: string[] = [] - for (const [, { props }] of groups) { - for (const p of props) { - sections.push(renderResponseField(p, resourceType)) - } - } - return sections.join('\n\n') -} - -function formatPropertyType(format: string, jsonType: string): string { - switch (format) { - case 'id': - return 'String (UUID)' - case 'datetime': - return 'String (ISO 8601)' - case 'enum': - return 'Enum (String)' - case 'list': - return 'Array' - case 'boolean': - return 'Boolean' - case 'string': - return 'String' - case 'object': - return 'Object' - default: - return jsonType - } -} - -function renderResourceSection( - resourceType: string, - description: string, - samples: Array<{ - title: string - description: string - properties: Record - }>, - properties: BlueprintProperty[], -): string { - const parts: string[] = [] - - parts.push(description) - - // Response example (right-side sticky JSON panel) - parts.push(renderCodeGroup(samples)) - - parts.push('---') - parts.push('## Properties') - parts.push(renderProperties(properties, resourceType)) - - // Grouped properties (e.g., Hardware, Access Codes on device) - const grouped = renderGroupedProperties(properties, resourceType) - if (grouped) { - parts.push(grouped) - } - - // Nested object properties (e.g., device.properties with sub-groups) - const propsField = properties.find( - (p) => p.name === 'properties' && p.format === 'object', - ) - if (propsField?.properties && propsField.properties.length > 0) { - const subGroups = propsField.propertyGroups ?? [] - if (subGroups.length > 0) { - for (const group of subGroups) { - const groupProps = propsField.properties.filter( - (p) => - p.propertyGroupKey === group.propertyGroupKey && - !shouldHide(resourceType, p), - ) - if (groupProps.length === 0) continue - parts.push(`## ${group.name}`) - parts.push( - groupProps - .map((p) => renderResponseField(p, resourceType)) - .join('\n\n'), - ) - } - } else { - const visibleChildren = propsField.properties.filter( - (p) => !shouldHide(resourceType, p), - ) - if (visibleChildren.length > 0) { - parts.push(`## ${resourceType}.properties`) - parts.push( - visibleChildren - .map((p) => renderResponseField(p, resourceType)) - .join('\n\n'), - ) - } - } - } - - return parts.join('\n\n') -} - -async function updateObjectPages( - bp: Blueprint, - docsDir: string, -): Promise { - const updated: string[] = [] - - const resourcesByRoute = new Map< - string, - Array<{ - resourceType: string - description: string - samples: Array<{ - title: string - description: string - properties: Record - }> - properties: BlueprintProperty[] - }> - >() - for (const resource of bp.resources) { - if (resource.resourceSamples.length === 0) continue - const existing = resourcesByRoute.get(resource.routePath) ?? [] - existing.push({ - resourceType: resource.resourceType, - description: resource.description, - samples: resource.resourceSamples, - properties: resource.properties as BlueprintProperty[], - }) - resourcesByRoute.set(resource.routePath, existing) - } - - for (const [routePath, resources] of resourcesByRoute) { - const objectPath = join(docsDir, 'api', routePath.slice(1), 'object.mdx') - - let content: string - try { - content = await readFile(objectPath, 'utf8') - } catch { - continue - } - - let changed = false - // Process resources in reverse order so index shifts don't affect earlier sections - for (const { resourceType, description, samples, properties } of [ - ...resources, - ].reverse()) { - const sectionHeader = `## The ${resourceType} Object` - const sectionIdx = content.indexOf(sectionHeader) - if (sectionIdx === -1) continue - - const sectionContentStart = content.indexOf('\n', sectionIdx) + 1 - - // Find end of this resource section: next "## The X Object" or end of file - const nextResourceMatch = /\n## The \w+ Object/m.exec( - content.slice(sectionContentStart), - ) - const sectionEnd = nextResourceMatch - ? sectionContentStart + nextResourceMatch.index - : content.length - - const newSection = renderResourceSection( - resourceType, - description, - samples, - properties, - ) - content = - content.slice(0, sectionContentStart) + - '\n' + - newSection + - '\n' + - content.slice(sectionEnd) - changed = true - } - - if (changed) { - await writeFile(objectPath, content) - updated.push(routePath) - } - } - - return updated -} diff --git a/mintlify-codegen/layouts/endpoints.hbs b/mintlify-codegen/layouts/endpoints.hbs new file mode 100644 index 000000000..a4671627e --- /dev/null +++ b/mintlify-codegen/layouts/endpoints.hbs @@ -0,0 +1,20 @@ +{{> frontmatter}} + +{{#if heading}} +## {{heading}} + +{{intro}} + +--- + +{{/if}} +## Endpoints + +{{#each endpoints}} +[**`{{path}}`**](./{{name}}) + +{{description}} +{{#unless @last}} + +{{/unless}} +{{/each}} diff --git a/mintlify-codegen/layouts/errors.hbs b/mintlify-codegen/layouts/errors.hbs new file mode 100644 index 000000000..4bd3015c4 --- /dev/null +++ b/mintlify-codegen/layouts/errors.hbs @@ -0,0 +1,53 @@ +{{> frontmatter}} + +{{#if note}} + + These are {{note.noun}}-specific {{note.phrase}}. For {{note.phrase}} common to all devices, see [Device Errors and Warnings]({{note.route}}). + + +{{/if}} +{{#each sections}} +## {{title}} +{{#if shape}} + +Each {{shape.kind}} is an object with the following shape: + +```json Example {{shape.kind}} +{{shape.json}} +``` + + + +{{#each shape.fields}} +{{> error-shape-property}} +{{#unless @last}} + +{{/unless}} +{{/each}} + + +{{/if}} +{{#each groups}} +{{#if name}} + +### {{name}} +{{/if}} +{{#if inherited}} + + + These errors are inherited from the {{inherited.link}} resource. When they are set on the parent {{inherited.nounLower}}, they are propagated to this resource's errors list. + +{{/if}} +{{#each entries}} + +{{level}} `{{code}}` + +{{description}} + +--- +{{/each}} +{{/each}} +{{#unless @last}} + +{{/unless}} +{{/each}} diff --git a/mintlify-codegen/layouts/events.hbs b/mintlify-codegen/layouts/events.hbs new file mode 100644 index 000000000..402d90920 --- /dev/null +++ b/mintlify-codegen/layouts/events.hbs @@ -0,0 +1,29 @@ +{{> frontmatter}} + +{{#each events}} +## `{{eventType}}` + +{{description}} + + + +```json Example webhook payload +{{sampleJson}} +``` + + + + + +{{#each properties}} +{{> event-property}} +{{#unless @last}} + +{{/unless}} +{{/each}} + + +{{#unless @last}} + +{{/unless}} +{{/each}} diff --git a/mintlify-codegen/layouts/legacy-object.hbs b/mintlify-codegen/layouts/legacy-object.hbs new file mode 100644 index 000000000..e93ccbbb7 --- /dev/null +++ b/mintlify-codegen/layouts/legacy-object.hbs @@ -0,0 +1,75 @@ +{{!-- Legacy (GitBook-migrated) object page presentation, regenerated from the +blueprint. TODO: modernize — ResponseField property blocks like object.hbs, +drop the duplicated Tabs sample (keep ResponseExample), collapse the double +blank line after the heading, /api-absolute endpoint links, and drop the +on-page Errors/Warnings sections now that dedicated errors pages exist. Each +piece is kept byte-compatible with the committed pages pending review. --}} +{{> frontmatter}} + +## {{heading}} + + +{{intro}} + +{{#if tabs.length}} +{{> legacy-sample-tabs}} + +{{/if}} +{{#if examples.length}} + + +{{#each examples}} +```json {{title}} +{{json}} +``` +{{#unless @last}} + +{{/unless}} +{{/each}} + + + +{{/if}} +--- + +{{#each propertySections}} +## {{heading}} + +{{#each properties}} +{{> legacy-property}} +{{/each}} +{{/each}} +{{#if errorEntries.length}} +## Errors + +{{#each errorEntries}} +**`{{code}}`** + +{{description}} + +--- + +{{/each}} +{{/if}} +{{#if warningEntries.length}} +## Warnings + +{{#each warningEntries}} +**`{{code}}`** + +{{description}} + +--- + +{{/each}} +{{/if}} +## Endpoints + +{{#each endpoints}} +[**`{{path}}`**](./{{name}}) + +{{description}} +{{#unless @last}} + +{{/unless}} +{{/each}} diff --git a/mintlify-codegen/layouts/namespace.hbs b/mintlify-codegen/layouts/namespace.hbs new file mode 100644 index 000000000..471ebf56a --- /dev/null +++ b/mintlify-codegen/layouts/namespace.hbs @@ -0,0 +1,22 @@ +{{> frontmatter}} + +{{intro}} + +## Resources + +{{#if resourcesIntro}} +{{resourcesIntro}} + +{{/if}} +{{!-- TODO: these self-anchor links reproduce the migrated page; consider +linking to each resource's object page (/api{{routePath}}/object) instead. --}} +{{#each resources}} +### [`{{resourceType}}`](#{{resourceType}}) + +{{description}} + +--- +{{#unless @last}} + +{{/unless}} +{{/each}} diff --git a/mintlify-codegen/layouts/object.hbs b/mintlify-codegen/layouts/object.hbs new file mode 100644 index 000000000..ac9a68cf2 --- /dev/null +++ b/mintlify-codegen/layouts/object.hbs @@ -0,0 +1,47 @@ +{{> frontmatter}} + +{{#each resources}} +## {{heading}} + +{{description}} + +{{#if samples.length}} + + +{{#each samples}} +```json {{title}} +{{json}} +``` +{{#unless @last}} + +{{/unless}} +{{/each}} + + + +{{/if}} +--- + +## Properties + +{{#each fields}} +{{> object-property}} +{{#unless @last}} + +{{/unless}} +{{/each}} +{{#each subGroups}} + +## {{heading}} + +{{#each fields}} +{{> object-property}} +{{#unless @last}} + +{{/unless}} +{{/each}} +{{/each}} +{{#unless @last}} + +{{/unless}} +{{/each}} diff --git a/mintlify-codegen/layouts/partials/error-shape-property.hbs b/mintlify-codegen/layouts/partials/error-shape-property.hbs new file mode 100644 index 000000000..d261843dd --- /dev/null +++ b/mintlify-codegen/layouts/partials/error-shape-property.hbs @@ -0,0 +1,9 @@ + +{{#indent 2}} +{{description}} +{{#if isDiscriminator}} + +One of the {{kind}} codes listed below. +{{/if}} +{{/indent}} + diff --git a/mintlify-codegen/layouts/partials/event-property.hbs b/mintlify-codegen/layouts/partials/event-property.hbs new file mode 100644 index 000000000..3e3eefa0a --- /dev/null +++ b/mintlify-codegen/layouts/partials/event-property.hbs @@ -0,0 +1,27 @@ +{{!-- TODO: nested children render at 6 spaces here (the whole body is indented ++2 after children are indented +4) while the object pages indent children at 4. +Reproduced from the old string renderers; align them in an output-changing +follow-up. --}} + +{{#indent 2}} +{{#if value}} +Value: `{{value}}` +{{else}} +{{description}} +{{#if possibleValues}} + +Possible values: {{possibleValues}} +{{/if}} +{{/if}} +{{#if children.length}} + + +{{#each children}} +{{#indent 4}} +{{> event-property}} +{{/indent}} +{{/each}} + +{{/if}} +{{/indent}} + diff --git a/mintlify-codegen/layouts/partials/frontmatter.hbs b/mintlify-codegen/layouts/partials/frontmatter.hbs new file mode 100644 index 000000000..edb59a5f9 --- /dev/null +++ b/mintlify-codegen/layouts/partials/frontmatter.hbs @@ -0,0 +1,4 @@ +--- +title: '{{escapeSingleQuotes title}}' +description: '{{escapeSingleQuotes description}}' +--- diff --git a/mintlify-codegen/layouts/partials/legacy-property.hbs b/mintlify-codegen/layouts/partials/legacy-property.hbs new file mode 100644 index 000000000..45c32d4f9 --- /dev/null +++ b/mintlify-codegen/layouts/partials/legacy-property.hbs @@ -0,0 +1,91 @@ +{{!-- TODO: modernize to the ResponseField format used by object-property.hbs; +this reproduces the migrated GitBook property block. --}} +{{#if linkAnchor}}[**`{{name}}`**]({{linkAnchor}}){{else}}**`{{name}}`**{{/if}} _{{formatLabel}}_{{#if listItemLabel}} _of {{listItemLabel}}s_{{/if}} + +{{#if description}} +{{description}} + +{{/if}} +{{#if deprecatedBlock}} +{{deprecatedBlock}} + +{{/if}} +{{#if enumValues}} + + +{{#each enumValues}} +- {{this}} +{{/each}} + + +{{/if}} +{{#if variants}} +The specific structure of each object in this list depends on the value of its `{{discriminator}}` field. + +Variants: + +{{#each variants}} + + +{{description}} + +{{#each properties}} +**`{{name}}`** _{{formatLabel}}_{{#if listItemLabel}} _of {{listItemLabel}}s_{{/if}} + +{{#if description}} +{{description}} + +{{/if}} +{{#if enumValues}} +Enum values: + +{{#each enumValues}} +- {{this}} +{{/each}} + +{{/if}} +{{#unless isLast}} +--- + +{{/unless}} +{{/each}} + +{{/each}} + +{{/if}} +{{#if childPropertiesBlock}} +{{childPropertiesBlock}} + +{{/if}} +{{#if childObjectProperties}} + +{{#each childObjectProperties}} +{{#if @first}} +{{name}} {{formatLabel}} +{{else}} + + {{name}} + +{{formatLabel}} +{{/if}} +{{#if enumValues}} + + +{{#each enumValues}} + - {{this}} +{{/each}} + + + +{{/if}} +{{#if description}} + +{{description}} + +{{/if}} +{{/each}} + + +{{/if}} +--- + diff --git a/mintlify-codegen/layouts/partials/legacy-sample-tabs.hbs b/mintlify-codegen/layouts/partials/legacy-sample-tabs.hbs new file mode 100644 index 000000000..7c30da867 --- /dev/null +++ b/mintlify-codegen/layouts/partials/legacy-sample-tabs.hbs @@ -0,0 +1,13 @@ + +{{#each tabs}} + + +{{description}} + +```json +{{json}} +``` + + +{{/each}} + diff --git a/mintlify-codegen/layouts/partials/object-property.hbs b/mintlify-codegen/layouts/partials/object-property.hbs new file mode 100644 index 000000000..b8381493d --- /dev/null +++ b/mintlify-codegen/layouts/partials/object-property.hbs @@ -0,0 +1,20 @@ + + {{description}} +{{#if enumValues}} + + +{{#each enumValues}} + - `{{this}}` +{{/each}} + +{{/if}} +{{#if children.length}} + +{{#each children}} +{{#indent 4}} +{{> object-property}} +{{/indent}} +{{/each}} + +{{/if}} + diff --git a/mintlify-codegen/canonicalize-links.ts b/mintlify-codegen/lib/canonicalize-links.ts similarity index 100% rename from mintlify-codegen/canonicalize-links.ts rename to mintlify-codegen/lib/canonicalize-links.ts diff --git a/mintlify-codegen/lib/config.ts b/mintlify-codegen/lib/config.ts new file mode 100644 index 000000000..82d149239 --- /dev/null +++ b/mintlify-codegen/lib/config.ts @@ -0,0 +1,53 @@ +/** Route path of the Event object page. Events do not emit events, so this + * path never appears in `blueprint.events`; its object page is special-cased to + * receive the full `event_type` enum. */ +export const eventObjectRoute = '/events' + +/** Route of the aggregate device errors/warnings page. Sub-categories that + * share the device-level codes link back to it. */ +export const commonDeviceErrorsRoute = '/api/devices/errors' + +export interface DeviceSubcategory { + // Route that carries the sub-category's dedicated errors page. + routePath: string + // Whether the sub-category shares the device-level errors/warnings documented + // on /api/devices/errors, and so should cross-link to them. The shared codes + // are physical-device connectivity/subscription errors (`device_offline`, + // `hub_disconnected`, `salto_ks_subscription_limit_exceeded`, …), relevant to + // locks and thermostats but not to phones (mobile devices used for + // credentials), so the phones page omits the cross-link. + linkCommonDeviceErrors: boolean +} + +// Some device sub-categories have their own docs route but no blueprint +// resource of their own — the Seam API models a lock, thermostat, or phone as a +// `device`. Their error/warning codes therefore live on the `device` resource +// under a variant group of the same key rather than on a resource of their own, +// so the per-resource loop never produces a page for them. This maps each such +// variant group to the route that should carry its dedicated page. +// +// The map is an explicit allowlist, not `'/' + groupKey`: groups like +// `access_codes`, `hardware`, and `provider_metadata` annotate the device page +// but have no standalone sub-category route. In particular /access_codes +// already documents the `access_code` resource's own, distinct errors — routing +// the device `access_codes` group there would clobber it. +export const deviceSubcategoryErrorRoutes: Record = { + locks: { routePath: '/locks', linkCommonDeviceErrors: true }, + phones: { routePath: '/phones', linkCommonDeviceErrors: false }, + thermostats: { routePath: '/thermostats', linkCommonDeviceErrors: true }, +} + +// Resources whose inherited error groups are restricted to an allowlist of +// variant groups (in addition to the always-included ungrouped variants). An +// error's `variant.resourceType` names the resource it belongs to, so a resource +// inherits the errors whose type differs from its own (e.g. an access code +// inherits its lock's device errors). On the access code pages, only +// lock-related inherited errors are relevant; broader device categories like +// thermostats or noise sensors are not. +export const inheritedErrorGroupAllowlist: Record = { + access_code: ['locks'], + unmanaged_access_code: ['locks'], +} + +/** Per-resource-type property names to omit from generated object pages. */ +export const hiddenProperties: Record> = {} diff --git a/mintlify-codegen/update-nav.ts b/mintlify-codegen/lib/docs-json.ts similarity index 88% rename from mintlify-codegen/update-nav.ts rename to mintlify-codegen/lib/docs-json.ts index 8ed18ef82..1f54b7bd5 100644 --- a/mintlify-codegen/update-nav.ts +++ b/mintlify-codegen/lib/docs-json.ts @@ -1,6 +1,30 @@ /* eslint-disable no-console */ -import { readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import type Metalsmith from 'metalsmith' + +import type { MintlifyMetadata } from './openapi.js' + +/** + * Metalsmith plugin: rewrite the API Reference tab of docs.json so endpoint + * pages become OpenAPI references (`POST /path`) rendered natively by Mintlify + * from openapi.json, and wire the generated events/errors pages into the + * sidebar next to their object page. Operates on `files['docs.json']` + * (ingested by the ingest plugin) and serializes once. + */ +export const docsJson: Metalsmith.Plugin = (files, metalsmith) => { + const metadata = metalsmith.metadata() as { mintlify: MintlifyMetadata } + const { specPaths, eventPageRoutes, errorPageRoutes } = metadata.mintlify + + const file = files['docs.json'] + if (file == null) throw new Error('docs.json was not ingested') + const docsJson = JSON.parse(file.contents.toString()) + + console.log('Updating docs.json navigation...') + updateApiTab(docsJson, specPaths) + insertEventsPagesIntoNav(docsJson, eventPageRoutes) + insertErrorPagesIntoNav(docsJson, errorPageRoutes) + + file.contents = Buffer.from(JSON.stringify(docsJson, null, 2) + '\n') +} /** * Static pages that should NOT be converted to OpenAPI references. @@ -113,28 +137,7 @@ function transformPages( .filter((p) => p !== null) } -export async function updateDocsJson(specPaths?: Set): Promise { - const docsJsonPath = join( - import.meta.dirname, - '..', - 'mintlify-docs', - 'docs.json', - ) - - const docsJson = JSON.parse(await readFile(docsJsonPath, 'utf8')) - - // If specPaths not provided, load from combined spec - if (!specPaths) { - const specPath = join( - import.meta.dirname, - '..', - 'mintlify-docs', - 'openapi.json', - ) - const spec = JSON.parse(await readFile(specPath, 'utf8')) - specPaths = new Set(Object.keys(spec.paths)) - } - +function updateApiTab(docsJson: any, specPaths: Set): void { // Find the API Reference tab const tabs = docsJson.navigation?.tabs if (!tabs) { @@ -192,8 +195,6 @@ export async function updateDocsJson(specPaths?: Set): Promise { } } - await writeFile(docsJsonPath, JSON.stringify(docsJson, null, 2) + '\n') - // Count what changed let openApiRefs = 0 let staticRefs = 0 @@ -365,21 +366,11 @@ function insertIntoMatchingGroup(groups: any[], path: string): boolean { /** * Insert each generated events page into the nav directly after its object * page (e.g. "api/access_grants/events" after "api/access_grants/object"). - * Idempotent: skips routes whose events page is already present. Call after the - * events pages have been written to disk. + * Idempotent: skips routes whose events page is already present. */ -export async function insertEventsPagesIntoNav( - eventRoutes: string[], -): Promise { +function insertEventsPagesIntoNav(docsJson: any, eventRoutes: string[]): void { if (eventRoutes.length === 0) return - const docsJsonPath = join( - import.meta.dirname, - '..', - 'mintlify-docs', - 'docs.json', - ) - const docsJson = JSON.parse(await readFile(docsJsonPath, 'utf8')) const apiTab = docsJson.navigation?.tabs?.find( (t: any) => t.tab === 'API Reference', ) @@ -396,7 +387,6 @@ export async function insertEventsPagesIntoNav( } if (inserted > 0) { - await writeFile(docsJsonPath, JSON.stringify(docsJson, null, 2) + '\n') console.log(` Added ${inserted} events page(s) to nav`) } } @@ -405,21 +395,12 @@ export async function insertEventsPagesIntoNav( * Insert each generated errors/warnings page into the nav after its events page * (falling back to its object page when there is no events page), e.g. * "api/devices/errors" after "api/devices/events". Idempotent: skips routes - * whose errors page is already present. Call after the errors pages have been - * written to disk and after events pages are wired in. + * whose errors page is already present. Runs after events pages are wired in so + * the sidebar reads object -> events -> errors. */ -export async function insertErrorPagesIntoNav( - errorRoutes: string[], -): Promise { +function insertErrorPagesIntoNav(docsJson: any, errorRoutes: string[]): void { if (errorRoutes.length === 0) return - const docsJsonPath = join( - import.meta.dirname, - '..', - 'mintlify-docs', - 'docs.json', - ) - const docsJson = JSON.parse(await readFile(docsJsonPath, 'utf8')) const apiTab = docsJson.navigation?.tabs?.find( (t: any) => t.tab === 'API Reference', ) @@ -438,7 +419,6 @@ export async function insertErrorPagesIntoNav( } if (inserted > 0) { - await writeFile(docsJsonPath, JSON.stringify(docsJson, null, 2) + '\n') console.log(` Added ${inserted} errors page(s) to nav`) } } @@ -483,8 +463,3 @@ function insertAfterPage( } return 'not-found' } - -// Run if called directly (standalone usage) -if (import.meta.url === `file://${process.argv[1]}`) { - updateDocsJson() -} diff --git a/mintlify-codegen/lib/format-type.ts b/mintlify-codegen/lib/format-type.ts new file mode 100644 index 000000000..fe40ee9f6 --- /dev/null +++ b/mintlify-codegen/lib/format-type.ts @@ -0,0 +1,75 @@ +/** + * Type labels for blueprint properties on generated API-reference pages. + * + * There are two variants because the pages they serve genuinely diverge in the + * committed output, and the generated bytes must not change: + * + * - `formatTypeBasic` (object pages) has no `record` case, so record-format + * properties fall through to the raw jsonType and render `type="object"`. + * - `formatType` (events/errors pages) maps `record` to `Object` and falls + * back to `String` when jsonType is absent. + * + * TODO: unify on `formatType` once an output-changing pass is acceptable — + * that flips `type="object"` to `type="Object"` (e.g. `custom_metadata`) and + * bare jsonTypes like `number` to capitalized labels on the object pages. + */ + +interface Formattable { + format: string + jsonType?: string +} + +/** Object-page variant (ported from generate.ts `formatPropertyType`). */ +export function formatTypeBasic(format: string, jsonType: string): string { + switch (format) { + case 'id': + return 'String (UUID)' + case 'datetime': + return 'String (ISO 8601)' + case 'enum': + return 'Enum (String)' + case 'list': + return 'Array' + case 'boolean': + return 'Boolean' + case 'string': + return 'String' + case 'object': + return 'Object' + default: + return jsonType + } +} + +/** Events/errors-page variant (ported from property-fields.ts `formatType`). */ +export function formatType(prop: Formattable): string { + switch (prop.format) { + case 'id': + return 'String (UUID)' + case 'datetime': + return 'String (ISO 8601)' + case 'enum': + return 'Enum (String)' + case 'list': + return 'Array' + case 'boolean': + return 'Boolean' + case 'string': + return 'String' + case 'object': + case 'record': + return 'Object' + default: + return prop.jsonType ?? 'String' + } +} + +/** Narrow a property to one that carries enum `values`. Generic so it preserves + * the caller's property type. */ +export function hasEnumValues( + prop: T, +): prop is T & { values: Array<{ name: string }> } { + return ( + 'values' in prop && Array.isArray((prop as { values?: unknown }).values) + ) +} diff --git a/mintlify-codegen/lib/handlebars-helpers.ts b/mintlify-codegen/lib/handlebars-helpers.ts new file mode 100644 index 000000000..7d4f8e396 --- /dev/null +++ b/mintlify-codegen/lib/handlebars-helpers.ts @@ -0,0 +1,13 @@ +import type { HelperOptions } from 'handlebars' + +import { escapeSingleQuotes as escape, indent as indentText } from './text.js' + +/** Escape single quotes for single-quoted frontmatter values. */ +export const escapeSingleQuotes = (value: string): string => escape(value) + +/** Block helper: indent every non-blank line of the block by `spaces` spaces. + * Mirrors the `indent()` text helper so recursive partials reproduce the + * nested `` indentation byte-for-byte. */ +export function indent(this: unknown, spaces: number, options: HelperOptions) { + return indentText(options.fn(this), spaces) +} diff --git a/mintlify-codegen/lib/index.ts b/mintlify-codegen/lib/index.ts new file mode 100644 index 000000000..322282d86 --- /dev/null +++ b/mintlify-codegen/lib/index.ts @@ -0,0 +1,13 @@ +import { handlebarsHelpers } from '@seamapi/smith' + +import * as customHelpers from './handlebars-helpers.js' + +export const helpers = { ...handlebarsHelpers, ...customHelpers } + +export * from './docs-json.js' +export * from './ingest.js' +export * from './missing-samples.js' +export * from './openapi.js' +export * from './postprocess.js' +export * from './reference.js' +export * from './report.js' diff --git a/mintlify-codegen/lib/ingest.ts b/mintlify-codegen/lib/ingest.ts new file mode 100644 index 000000000..5600dee8f --- /dev/null +++ b/mintlify-codegen/lib/ingest.ts @@ -0,0 +1,18 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +import type Metalsmith from 'metalsmith' + +/** + * Metalsmith plugin: read docs.json from the destination tree into `files[]` + * so downstream plugins (docs-json nav surgery, link canonicalization) operate + * on it in memory and Metalsmith writes the result back with the build. + * + * docs.json is the one hand-maintained file the pipeline updates in place: the + * Guides tabs are authored, while the API Reference tab and redirects are + * pipeline-managed. + */ +export const ingest: Metalsmith.Plugin = async (files, metalsmith) => { + const contents = await readFile(join(metalsmith.destination(), 'docs.json')) + files['docs.json'] = { contents } as (typeof files)[string] +} diff --git a/mintlify-codegen/lib/layout/endpoints-page.ts b/mintlify-codegen/lib/layout/endpoints-page.ts new file mode 100644 index 000000000..352a89f08 --- /dev/null +++ b/mintlify-codegen/lib/layout/endpoints-page.ts @@ -0,0 +1,49 @@ +import type { Blueprint } from '@seamapi/blueprint' + +import type { ObjectPageEntry } from '../object-page-metadata.js' + +export interface EndpointsPageLayoutContext { + layout: string + contents: Buffer + title: string + description: string + heading?: string | undefined + intro?: string | undefined + endpoints: Array<{ path: string; name: string; description: string }> +} + +/** + * Build the layout context for an endpoint-listing page (`endpoints: true` in + * object-pages.yaml) from the blueprint route at the page's route path — the + * Mintlify analog of the GitBook route README's Endpoints section. An optional + * `heading` + `intro` section precedes the listing (e.g. /noise_sensors). + * Returns null when the blueprint has no such route or it has no documented + * endpoints. + */ +export function setEndpointsPageLayoutContext( + blueprint: Blueprint, + routePath: string, + meta: ObjectPageEntry, +): EndpointsPageLayoutContext | null { + const route = blueprint.routes.find((r) => r.path === routePath) + if (route == null) return null + + const endpoints = route.endpoints + .filter((endpoint) => !endpoint.isUndocumented) + .map((endpoint) => ({ + path: endpoint.path, + name: endpoint.path.split('/').filter(Boolean).pop() ?? endpoint.path, + description: endpoint.description.trim(), + })) + if (endpoints.length === 0) return null + + return { + layout: 'endpoints.hbs', + contents: Buffer.from(''), + title: meta.title, + description: meta.description, + heading: meta.heading, + intro: meta.intro?.trim(), + endpoints, + } +} diff --git a/mintlify-codegen/lib/layout/errors-page.ts b/mintlify-codegen/lib/layout/errors-page.ts new file mode 100644 index 000000000..495b0dd8f --- /dev/null +++ b/mintlify-codegen/lib/layout/errors-page.ts @@ -0,0 +1,437 @@ +import type { Blueprint, DiscriminatedListProperty } from '@seamapi/blueprint' + +import { formatType } from '../format-type.js' +import { sampleValue } from '../sample-value.js' + +type Resource = Blueprint['resources'][number] +export type Property = Resource['properties'][number] + +interface CodeEntry { + code: string + description: string +} + +interface CodeGroup { + // Group heading (e.g. "Locks"); null for the ungrouped variants. + name: string | null + entries: CodeEntry[] + // Set on inherited-error groups: the parent resource the errors come from and + // a link to its own errors page. Drives the explanatory callout. + inheritedFrom?: { + noun: string + href?: string + } +} + +interface ShapeFieldContext { + name: string + type: string + description: string + // The discriminator field gets a `One of the codes listed below.` line. + isDiscriminator: boolean + kind: string +} + +interface SectionGroupContext { + name: string | null + inherited?: { link: string; nounLower: string } | undefined + entries: Array<{ level: string; code: string; description: string }> +} + +interface SectionContext { + title: string + shape?: + | { + kind: string + accordionTitle: string + json: string + fields: ShapeFieldContext[] + } + | undefined + groups: SectionGroupContext[] +} + +export interface ErrorsPageLayoutContext { + layout: string + contents: Buffer + title: string + description: string + note?: { noun: string; phrase: string; route: string } | undefined + sections: SectionContext[] +} + +export function isDiscriminatedListProperty( + prop: Property | undefined, +): prop is Property & DiscriminatedListProperty { + return ( + prop != null && + 'itemFormat' in prop && + prop.itemFormat === 'discriminated_object' + ) +} + +/** + * Read the enumerated code (the discriminator enum's single value) from a + * variant's properties, e.g. `error_code = "device_offline"`. + */ +function variantCode( + variant: DiscriminatedListProperty['variants'][number], + discriminator: string, +): string | null { + const prop = variant.properties.find( + (p) => p.name === discriminator && p.format === 'enum', + ) as { values?: Array<{ name: string }> } | undefined + return prop?.values?.[0]?.name ?? null +} + +/** Map a list of variants to sorted code entries, dropping any without a + * discriminator code. */ +function variantsToEntries( + variants: DiscriminatedListProperty['variants'], + discriminator: string, +): CodeEntry[] { + return variants + .map((v) => { + const code = variantCode(v, discriminator) + return code == null + ? null + : { code, description: (v.description ?? '').trim() } + }) + .filter((e): e is CodeEntry => e != null) + .sort((a, b) => a.code.localeCompare(b.code)) +} + +/** + * Group a resource's `errors` or `warnings` property into ordered code groups: + * the ungrouped variants first (no heading), then each named variant group in + * blueprint order. Entries within a group are sorted by code. Returns an empty + * array when the property is absent or has no documented variants. + */ +export function groupCodes(prop: Property | undefined): CodeGroup[] { + if (!isDiscriminatedListProperty(prop)) return [] + + const entriesFor = (key: string | null): CodeEntry[] => + variantsToEntries( + prop.variants.filter((v) => v.variantGroupKey === key), + prop.discriminator, + ) + + const groups: CodeGroup[] = [{ name: null, entries: entriesFor(null) }] + for (const group of prop.variantGroups) { + groups.push({ + name: group.name, + entries: entriesFor(group.variantGroupKey), + }) + } + return groups.filter((g) => g.entries.length > 0) +} + +/** Convert a resource type (`connected_account`) into a display noun + * (`Connected Account`) for an inherited-error group heading. */ +function resourceTypeNoun(resourceType: string): string { + return resourceType + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} + +/** + * Group a resource's `errors` property following the inheritance model: first + * the errors that belong to the resource itself (whose `variant.resourceType` + * matches), grouped by variant group exactly like `groupCodes`; then, for each + * parent resource whose errors this resource inherits (variants with a different + * `resourceType`), a single flat group per parent — variant groups are ignored, + * and the group is headed by the parent's noun. Inherited groups are ordered by + * parent resource type. When `inheritedGroupAllowlist` is set, inherited errors + * are limited to the ungrouped variants plus the listed variant groups. + */ +export function groupErrorCodes( + prop: Property | undefined, + resourceType: string, + inheritedGroupAllowlist?: string[], + errorsHrefByResourceType?: Record, +): CodeGroup[] { + if (!isDiscriminatedListProperty(prop)) return [] + + // Unmanaged resources carry their managed counterpart's resource type on + // their variants (`unmanaged_access_code` errors are tagged `access_code`), so + // strip the `unmanaged_` prefix to identify the resource's own errors. A + // variant with no resource type is treated as the resource's own. + const ownResourceType = resourceType.replace(/^unmanaged_/, '') + const isOwn = ( + variant: DiscriminatedListProperty['variants'][number], + ): boolean => + variant.resourceType == null || variant.resourceType === ownResourceType + + // Errors matching the resource's own type, grouped by variant group. + const ownGroups = groupCodes({ + ...prop, + variants: prop.variants.filter(isOwn), + }) + + // Errors inherited from parent resources, one flat group per parent resource. + const parentResourceTypes = [ + ...new Set( + prop.variants + .map((v) => v.resourceType) + .filter((t): t is string => t != null && t !== ownResourceType), + ), + ].sort() + + const isAllowedGroup = (variantGroupKey: string | null): boolean => + inheritedGroupAllowlist == null || + variantGroupKey == null || + inheritedGroupAllowlist.includes(variantGroupKey) + + const inheritedGroups: CodeGroup[] = parentResourceTypes + .map((parentResourceType): CodeGroup => { + const variants = prop.variants.filter( + (v) => + v.resourceType === parentResourceType && + isAllowedGroup(v.variantGroupKey), + ) + const href = errorsHrefByResourceType?.[parentResourceType] + return { + name: resourceTypeNoun(parentResourceType), + entries: variantsToEntries(variants, prop.discriminator), + inheritedFrom: { + noun: resourceTypeNoun(parentResourceType), + ...(href == null ? {} : { href }), + }, + } + }) + .filter((g) => g.entries.length > 0) + + return [...ownGroups, ...inheritedGroups] +} + +/** + * Order an object's properties for display: the discriminator first, then + * `message` and `created_at`, then everything else alphabetically. Keeps the + * example payload and properties list readable and consistent across pages. + */ +function orderProperties(props: Property[], discriminator: string): Property[] { + const priority = [discriminator, 'message', 'created_at'] + const rank = (name: string): number => { + const i = priority.indexOf(name) + return i === -1 ? priority.length : i + } + return [...props].sort( + (a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name), + ) +} + +/** + * The union of properties across a discriminated list's variants, deduplicated + * by name (first occurrence wins). Variants share a core shape (`error_code` or + * `warning_code`, `message`, `created_at`) plus a few variant-specific flags, so + * the union documents every field a reader might encounter. + */ +function unionProperties(prop: DiscriminatedListProperty): Property[] { + const byName = new Map() + for (const variant of prop.variants) { + for (const p of variant.properties) { + if (!byName.has(p.name)) byName.set(p.name, p) + } + } + return orderProperties([...byName.values()] as Property[], prop.discriminator) +} + +/** Strip Markdown link and inline-code syntax so a description reads as the + * plain-text string an API `message` field would actually contain + * (`[access grant](https://…)` -> `access grant`). */ +function toPlainText(md: string): string { + return md + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .trim() +} + +/** Build an example object from one variant: the concrete code and a plain-text + * message, with fixed sample values for the rest. */ +function buildExample( + variant: DiscriminatedListProperty['variants'][number], + discriminator: string, + code: string, +): Record { + const example: Record = {} + for (const p of orderProperties( + variant.properties as Property[], + discriminator, + )) { + if (p.name === discriminator) example[p.name] = code + else if (p.name === 'message') { + example[p.name] = + toPlainText(variant.description ?? '') || 'A human-readable message.' + } else example[p.name] = sampleValue(p) + } + return example +} + +/** + * Build the object-shape context for a section: an example payload (built from + * the first variant) and the union of every variant's properties. Returns + * undefined when the property is missing or has no variants. + */ +function buildShape( + prop: Property | undefined, + kind: string, +): SectionContext['shape'] { + if (!isDiscriminatedListProperty(prop)) return undefined + const first = prop.variants[0] + if (first == null) return undefined + + const code = variantCode(first, prop.discriminator) ?? '' + const title = kind.charAt(0).toUpperCase() + kind.slice(1) + + return { + kind, + accordionTitle: `${title} object properties`, + json: JSON.stringify( + buildExample(first, prop.discriminator, code), + null, + 2, + ), + fields: unionProperties(prop).map((p) => ({ + name: p.name, + type: formatType(p), + description: + (p.description ?? '').trim() || `The ${p.name.replace(/_/g, ' ')}.`, + isDiscriminator: p.name === prop.discriminator, + kind, + })), + } +} + +function buildSection( + title: string, + kind: string, + prop: Property | undefined, + groups: CodeGroup[], +): SectionContext | undefined { + if (groups.length === 0) return undefined + return { + title, + shape: buildShape(prop, kind), + groups: groups.map((group) => ({ + name: group.name, + inherited: + group.inheritedFrom == null + ? undefined + : { + link: + group.inheritedFrom.href == null + ? group.inheritedFrom.noun + : `[${group.inheritedFrom.noun}](${group.inheritedFrom.href})`, + nounLower: group.inheritedFrom.noun.toLowerCase(), + }, + entries: group.entries.map((entry) => ({ + // Named variant groups nest their codes at `####`; ungrouped codes sit + // directly under the section at `###`. + level: group.name != null ? '####' : '###', + code: entry.code, + description: + entry.description || `Indicates the \`${entry.code}\` state.`, + })), + })), + } +} + +/** + * Narrow a device `errors`/`warnings` property to a single variant group, + * flattening the kept variants to ungrouped (`variantGroupKey` cleared) so their + * codes render as top-level entries — a group heading would be redundant on a + * page already scoped to that sub-category. Returns undefined when the property + * is absent or the group has no variants, so the caller emits nothing. + */ +export function variantGroupProp( + prop: Property | undefined, + groupKey: string, +): (Property & DiscriminatedListProperty) | undefined { + if (!isDiscriminatedListProperty(prop)) return undefined + const variants = prop.variants + .filter((v) => v.variantGroupKey === groupKey) + .map((v) => ({ ...v, variantGroupKey: null })) + if (variants.length === 0) return undefined + return { ...prop, variants, variantGroups: [] } +} + +/** The `Errors`/`Warnings`/`Errors and Warnings` suffix for the given sections. */ +function kindSuffix(hasErrors: boolean, hasWarnings: boolean): string { + if (hasErrors && hasWarnings) return 'Errors and Warnings' + return hasErrors ? 'Errors' : 'Warnings' +} + +export interface ErrorsPageOptions { + // When set, prepend a note linking to the errors/warnings shared by all + // devices. Used only for device sub-category pages (locks/thermostats/phones), + // which are subsets that omit the device-level codes. + commonDeviceErrorsRoute?: string + // When set, errors are grouped by the inheritance model (own errors first, + // then a flat group per parent resource) keyed on this resource type. Used for + // real per-resource pages; omitted for device sub-category pages, which are + // already scoped to a single variant group. + inheritanceResourceType?: string + // Restricts inherited errors to these variant groups (plus ungrouped ones). + inheritedGroupAllowlist?: string[] | undefined + // Maps a resource type to its errors page href, used to link an inherited + // group's callout back to the parent resource's own errors page. + errorsHrefByResourceType?: Record +} + +/** + * Build the layout context for a resource's errors/warnings page. Returns null + * when the resource has no documented codes, so the caller emits nothing. + */ +export function setErrorsPageLayoutContext( + noun: string, + errorsProp: Property | undefined, + warningsProp: Property | undefined, + options: ErrorsPageOptions = {}, +): ErrorsPageLayoutContext | null { + const errorGroups = + options.inheritanceResourceType != null + ? groupErrorCodes( + errorsProp, + options.inheritanceResourceType, + options.inheritedGroupAllowlist, + options.errorsHrefByResourceType, + ) + : groupCodes(errorsProp) + const warningGroups = groupCodes(warningsProp) + if (errorGroups.length === 0 && warningGroups.length === 0) return null + + const errorSection = buildSection('Errors', 'error', errorsProp, errorGroups) + const warningSection = buildSection( + 'Warnings', + 'warning', + warningsProp, + warningGroups, + ) + + const title = `${noun} ${kindSuffix(errorSection != null, warningSection != null)}` + const kinds = + errorSection != null && warningSection != null + ? 'Errors and warnings' + : errorSection != null + ? 'Errors' + : 'Warnings' + + return { + layout: 'errors.hbs', + contents: Buffer.from(''), + title, + description: `${kinds} that Seam reports on the ${noun} resource, each with its code and meaning.`, + note: + options.commonDeviceErrorsRoute == null + ? undefined + : { + noun, + phrase: kinds.toLowerCase(), + route: options.commonDeviceErrorsRoute, + }, + sections: [errorSection, warningSection].filter( + (s): s is SectionContext => s != null, + ), + } +} diff --git a/mintlify-codegen/lib/layout/events-page.ts b/mintlify-codegen/lib/layout/events-page.ts new file mode 100644 index 000000000..867b8f41b --- /dev/null +++ b/mintlify-codegen/lib/layout/events-page.ts @@ -0,0 +1,139 @@ +import type { Blueprint } from '@seamapi/blueprint' + +import { formatType, hasEnumValues } from '../format-type.js' +import { sampleValueDeep } from '../sample-value.js' + +type EventResource = Blueprint['events'][number] +type EventProperty = EventResource['properties'][number] + +export interface EventPropertyContext { + name: string + type: string + // Set for a single-value `event_type` enum: render `Value: \`x\`` instead of + // the description body. + value?: string + description?: string + possibleValues?: string + children: EventPropertyContext[] +} + +interface EventContext { + eventType: string + description: string + sampleJson: string + properties: EventPropertyContext[] +} + +export interface EventsPageLayoutContext { + layout: string + contents: Buffer + title: string + description: string + events: EventContext[] +} + +/** + * Object-format properties (e.g. the `from`/`to` of a `*.*_changed` event) + * carry their own typed `properties` array. Free-form records + * (`*_custom_metadata`) have an empty array; those still render as `{}`. + */ +function hasNestedProperties( + prop: EventProperty, +): prop is EventProperty & { properties: EventProperty[] } { + return ( + 'properties' in prop && + Array.isArray((prop as { properties?: unknown }).properties) && + (prop as { properties: unknown[] }).properties.length > 0 + ) +} + +/** + * Synthesize an example webhook payload for an event so readers can see its + * shape without triggering the event. `event_type` gets the concrete type and + * `event_description` echoes the event's description (matching the real + * payload); every other field gets a type-appropriate placeholder. + */ +function buildEventSample(event: EventResource): Record { + const sample: Record = {} + for (const prop of event.properties) { + if (prop.isUndocumented) continue + if (prop.name === 'event_type') { + sample[prop.name] = event.eventType + } else if (prop.name === 'event_description') { + sample[prop.name] = event.description.trim() + } else { + sample[prop.name] = sampleValueDeep(prop) + } + } + return sample +} + +function buildEventProperty(prop: EventProperty): EventPropertyContext { + const context: EventPropertyContext = { + name: prop.name, + type: formatType(prop), + children: [], + } + + // `event_type` is a single-value enum: show the concrete value. + if ( + prop.name === 'event_type' && + hasEnumValues(prop) && + prop.values.length === 1 && + prop.values[0] != null + ) { + context.value = prop.values[0].name + } else { + const description = (prop.description ?? '').trim() + context.description = description || `The ${prop.name.replace(/_/g, ' ')}.` + + if (hasEnumValues(prop) && prop.values.length > 0) { + context.possibleValues = prop.values + .map((value) => `\`${value.name}\``) + .join(', ') + } + } + + // Typed objects (a `*_changed` event's `from`/`to`) document their child + // fields in a nested ``, matching the object pages. + if (hasNestedProperties(prop)) { + context.children = prop.properties + .filter((child) => !child.isUndocumented) + .map(buildEventProperty) + } + + return context +} + +/** Group events by the route path of the resource that emits them. */ +export function groupEventsByRoutePath( + events: EventResource[], +): Map { + const byRoute = new Map() + for (const event of events) { + const group = byRoute.get(event.routePath) ?? [] + group.push(event) + byRoute.set(event.routePath, group) + } + return byRoute +} + +export function setEventsPageLayoutContext( + events: EventResource[], + noun: string, +): EventsPageLayoutContext { + return { + layout: 'events.hbs', + contents: Buffer.from(''), + title: `${noun} Events`, + description: `Webhook events that Seam emits for the ${noun} resource, with example payloads and properties.`, + events: events.map((event) => ({ + eventType: event.eventType, + description: event.description.trim(), + sampleJson: JSON.stringify(buildEventSample(event), null, 2), + properties: event.properties + .filter((prop) => !prop.isUndocumented) + .map(buildEventProperty), + })), + } +} diff --git a/mintlify-codegen/lib/layout/legacy-object-page.ts b/mintlify-codegen/lib/layout/legacy-object-page.ts new file mode 100644 index 000000000..67a4f3800 --- /dev/null +++ b/mintlify-codegen/lib/layout/legacy-object-page.ts @@ -0,0 +1,797 @@ +import type { Blueprint, DiscriminatedListProperty } from '@seamapi/blueprint' +import { pascalCase } from 'change-case' + +import type { ObjectPageEntry } from '../object-page-metadata.js' +import { isDiscriminatedListProperty } from './errors-page.js' + +/** + * Layout contexts for the "legacy" object pages (/locks, /thermostats, + * /action_attempts): pages whose committed presentation is the migrated + * GitBook format (`**\`name\`** _Boolean_` property blocks, `---` separators, + * `` enums, on-page Errors/Warnings/Endpoints sections). + * + * The composition algorithms are ported VERBATIM from + * codegen/lib/layout/api-route.ts — byte-level agreement with the GitBook + * pipeline's output for these routes is the contract, quirks included (e.g. + * the non-total variant comparator in groupVariants). Do not "fix" them here; + * the pages carry TODOs to modernize the presentation instead. + */ + +type Resource = Blueprint['resources'][number] +type Property = Resource['properties'][number] +type PropertyGroup = { name: string; propertyGroupKey: string } +type Endpoint = Blueprint['routes'][number]['endpoints'][number] + +const printWidth = 80 + +interface RouteProperty { + name: string + description: string + isDeprecated: boolean + deprecationMessage: string + format: string + listItemFormat: string + value?: string + enumValues?: string[] + objectProperties?: RouteProperty[] + listProperties?: RouteProperty[] + discriminator?: string + discriminatorVariants?: Array<{ + name: string + description: string + properties: RouteProperty[] + }> +} + +interface RouteVariant { + name: string + description: string + parentResouceType: string | null +} + +/** Render-ready property block context for legacy-property.hbs. */ +interface LegacyPropertyContext { + name: string + formatLabel: string + listItemLabel?: string | undefined + linkAnchor?: string | undefined + description?: string | undefined + deprecatedBlock?: string | undefined + enumValues?: string[] | undefined + discriminator?: string | undefined + variants?: + | Array<{ + name: string + description: string + properties: Array<{ + name: string + formatLabel: string + listItemLabel?: string | undefined + description?: string | undefined + enumValues?: string[] | undefined + isLast: boolean + }> + }> + | undefined + childPropertiesBlock?: string | undefined + childObjectProperties?: + | Array<{ + name: string + formatLabel: string + description?: string | undefined + enumValues?: string[] | undefined + }> + | undefined +} + +export interface LegacyObjectPageLayoutContext { + layout: string + contents: Buffer + title: string + description: string + heading: string + intro: string + tabs: Array<{ title: string; description: string; json: string }> + examples: Array<{ title: string; json: string }> + propertySections: Array<{ + heading: string + properties: LegacyPropertyContext[] + }> + errorEntries: Array<{ code: string; description: string }> + warningEntries: Array<{ code: string; description: string }> + endpoints: Array<{ path: string; name: string; description: string }> +} + +// --- Ported verbatim from codegen/lib/layout/api-route.ts ------------------ + +export const normalizePropertyFormatForDocs = (format: string): string => { + const formatMap: Record = { + id: 'UUID', + discriminated_object: 'Object', + } + return formatMap[format] ?? pascalCase(format) +} + +const getFirstParagraph = (text: string): string => + text.split('\n\n').at(0) ?? text + +const flattenObjectProperties = ( + properties: Property[], + paths: string[] = [], +): Property[] => { + const results: Property[] = [] + + for (const property of properties.filter( + ({ isUndocumented }) => !isUndocumented, + )) { + const name = [...paths, property.name].join('.') + + results.push({ + ...property, + name, + } as Property) + + if (property.format === 'object') { + results.push(...flattenObjectProperties(property.properties, [name])) + continue + } + } + + return results +} + +const findEnumProperty = ( + properties: Property[], + name: string, +): { values?: Array<{ name: string }> } | null => { + const prop = properties.find((p) => p.name === name && p.format === 'enum') + return (prop as { values?: Array<{ name: string }> } | undefined) ?? null +} + +const mapBlueprintPropertyToRouteProperty = (prop: Property): RouteProperty => { + const { name, description, format, isDeprecated, deprecationMessage } = prop + const contextRouteProp: RouteProperty = { + name, + description: description.trim(), + isDeprecated, + deprecationMessage, + format: normalizePropertyFormatForDocs(format), + listItemFormat: '', + } + + if ('values' in prop) { + const singleValueEnumProps = ['event_type', 'action_type'] + if ( + singleValueEnumProps.includes(name) && + prop.values.length === 1 && + prop.values[0] != null + ) { + contextRouteProp.value = prop.values[0].name + } else { + contextRouteProp.enumValues = prop.values.map(({ name }) => name) + } + } + + if ('properties' in prop) { + const flattenedProperties = flattenObjectProperties(prop.properties) + contextRouteProp.objectProperties = flattenedProperties.map( + mapBlueprintPropertyToRouteProperty, + ) + } + + if ('itemProperties' in prop) { + const flattenedProperties = flattenObjectProperties( + (prop as { itemProperties: Property[] }).itemProperties, + ) + contextRouteProp.listProperties = flattenedProperties.map( + mapBlueprintPropertyToRouteProperty, + ) + } + + if (format === 'list') { + contextRouteProp.listItemFormat = normalizePropertyFormatForDocs( + (prop as { itemFormat: string }).itemFormat, + ) + + if (isDiscriminatedListProperty(prop)) { + const discriminatedListProp = prop + contextRouteProp.discriminator = discriminatedListProp.discriminator + contextRouteProp.discriminatorVariants = + discriminatedListProp.variants.map((variant) => { + const discriminatorProperty = findEnumProperty( + variant.properties as Property[], + discriminatedListProp.discriminator, + ) + const variantName = + discriminatorProperty?.values?.[0]?.name ?? 'unknown_variant' + return { + name: variantName, + description: variant.description, + properties: (variant.properties as Property[]) + .filter((p) => !p.isUndocumented) + .map(mapBlueprintPropertyToRouteProperty), + } + }) + } + } + + return contextRouteProp +} + +export const groupProperties = ( + properties: Property[], + propertyGroups: PropertyGroup[], + { + include, + exclude, + }: { + include?: string[] | undefined + exclude?: string[] | undefined + }, +): RouteProperty[] => { + const getApiRouteProperties = ( + propertyGroupKey: string | null, + ): RouteProperty[] => + properties + .filter( + (p) => + (p as { propertyGroupKey?: string | null }).propertyGroupKey === + propertyGroupKey, + ) + .map(mapBlueprintPropertyToRouteProperty) + .sort((a, b) => { + return a.name.localeCompare(b.name) + }) + + const groups = propertyGroups + .reduce< + Array<{ propertyGroupKey: string | null; properties: RouteProperty[] }> + >( + (groups, propertyGroup) => [ + ...groups, + { + propertyGroupKey: propertyGroup.propertyGroupKey, + properties: getApiRouteProperties(propertyGroup.propertyGroupKey), + }, + ], + [ + { + properties: getApiRouteProperties(null), + propertyGroupKey: null, + }, + ], + ) + .filter(({ properties }) => properties.length > 0) + .filter(({ propertyGroupKey }) => { + if (include == null) return true + if (propertyGroupKey == null) return true + return include.includes(propertyGroupKey) + }) + .filter(({ propertyGroupKey }) => { + if (exclude == null) return true + if (propertyGroupKey == null) return true + return !exclude.includes(propertyGroupKey) + }) + + // With `include` set, the GitBook pipeline collapses to one flat + // alphabetically sorted group (api-route.ts:429-439). These legacy pages + // always set include_groups, so only that path is ported. + return groups + .flatMap((g) => g.properties) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +const getParentVariantResourceType = ( + propertyKeys: string[], + resourceTypes: string[], +): string | null => { + const keyMap = Object.fromEntries( + resourceTypes.map((k) => [`is_${k}_error`, k]), + ) + const key = propertyKeys.find((k) => Object.keys(keyMap).includes(k)) + if (key == null) return null + return keyMap[key] ?? null +} + +const collectResourceVariants = ( + property: DiscriminatedListProperty, + resourceTypes: string[], +): RouteVariant[] => { + return property.variants + .map((variant) => { + const discriminator = findEnumProperty( + variant.properties as Property[], + property.discriminator, + ) + if (discriminator?.values?.[0]?.name == null) { + return null + } + + return { + name: discriminator.values[0].name, + description: variant.description, + parentResouceType: getParentVariantResourceType( + variant.properties.map(({ name }) => name), + resourceTypes, + ), + } + }) + .filter((variant): variant is RouteVariant => variant !== null) +} + +export const groupVariants = ( + property: Property | null | undefined, + { + include, + exclude, + }: { + include?: string[] | undefined + exclude?: string[] | undefined + }, + resourceType: string, + resourceTypes: string[], +): RouteVariant[] => { + if (!isDiscriminatedListProperty(property ?? undefined)) { + return [] + } + const prop = property as Property & DiscriminatedListProperty + + const getApiRouteVariants = ( + variantGroupKey: string | null, + ): RouteVariant[] => { + return collectResourceVariants( + { + ...prop, + variants: prop.variants.filter( + (v) => v.variantGroupKey === variantGroupKey, + ), + }, + resourceTypes, + ).sort((a, b) => { + return a.name.localeCompare(b.name) + }) + } + + const groups = prop.variantGroups + .reduce< + Array<{ variantGroupKey: string | null; variants: RouteVariant[] }> + >( + (groups, variantGroup) => [ + ...groups, + { + variantGroupKey: variantGroup.variantGroupKey, + variants: getApiRouteVariants(variantGroup.variantGroupKey), + }, + ], + [ + { + variants: getApiRouteVariants(null), + variantGroupKey: null, + }, + ], + ) + .filter(({ variants }) => variants.length > 0) + .filter(({ variantGroupKey }) => { + if (include == null) return true + if (variantGroupKey == null) return true + return include.includes(variantGroupKey) + }) + .filter(({ variantGroupKey }) => { + if (exclude == null) return true + if (variantGroupKey == null) return true + return !exclude.includes(variantGroupKey) + }) + + // With `include` set, variants collapse to one flat list under the ported + // comparator (api-route.ts:303-333), non-total quirks included. + return groups + .flatMap((g) => g.variants) + .sort((a, b) => { + if (a.parentResouceType === null && b.parentResouceType === null) { + return a.name.localeCompare(b.name) + } + if (a.parentResouceType === resourceType) { + return -1 + } + if (b.parentResouceType === resourceType) { + return 1 + } + if (a.parentResouceType === null) { + return -1 + } + if (b.parentResouceType === null) { + return 1 + } + if (a.parentResouceType !== b.parentResouceType) { + return a.parentResouceType.localeCompare(b.parentResouceType) + } + return a.name.localeCompare(b.name) + }) +} + +// --- Legacy rendering helpers ---------------------------------------------- + +/** + * Render a `**Deprecated**. …` marker: inline when the + * whole line fits in the 80-column print width, otherwise a block form with + * the text greedily filled at the print width and indented two spaces — + * matching the prettier-mdx state of the committed pages. + */ +export function renderDeprecatedBlock(message: string, indent = 0): string { + const pad = ' '.repeat(indent) + // The inline/block threshold measures the marker without its indent — a + // 2-space-indented 80-char marker stays inline in the committed pages. + const marker = `**Deprecated**. ${message}` + if (marker.length <= printWidth) return pad + marker + + const words = `**Deprecated**. ${message}`.split(/\s+/) + const contentPad = ' '.repeat(indent + 2) + const lines: string[] = [] + let line = contentPad + for (const word of words) { + const candidate = line === contentPad ? line + word : `${line} ${word}` + if (candidate.length > printWidth && line !== contentPad) { + lines.push(line) + line = contentPad + word + } else { + line = candidate + } + } + lines.push(line) + return [`${pad}`, ...lines, `${pad}`].join('\n') +} + +/** + * Render a legacy `Child Properties` accordion: the flattened children of an + * object property as adjacent `` blocks. + * + * TODO: the committed pages contain converter artifacts in a few of these + * blocks (mis-nested fields after a list-of-objects child, e.g. + * assa_abloy_credential_service_metadata; the dormakaba predefined_time_slots + * tail). This renders the flattened children flat and consistent instead of + * reproducing those glitches — expect small reviewed diffs there. + */ +function renderChildPropertiesBlock(children: RouteProperty[]): string { + const renderField = (child: RouteProperty): string => { + const type = + child.format === 'List' + ? `List of ${child.listItemFormat}s` + : child.format + const body: string[] = [] + if (child.description) body.push(` ${child.description}`) + if (child.enumValues != null && child.enumValues.length > 0) { + body.push( + '', + ' ', + '', + ...child.enumValues.map((value) => ` - \`${value}\``), + '', + ' ', + ) + } + if (child.isDeprecated) { + body.push(renderDeprecatedBlock(child.deprecationMessage, 2)) + } + return [ + ``, + ...body, + ``, + ].join('\n') + } + + const fields: string[] = [] + for (const child of children) { + fields.push(renderField(child)) + // A list-of-objects child documents its item properties as adjacent + // sibling fields (matching the committed pages' flattened presentation). + if (child.listProperties != null) { + for (const item of child.listProperties) { + fields.push(renderField(item)) + } + } + } + + return [ + '', + '', + fields.join('\n'), + '', + ].join('\n') +} + +const toLegacyPropertyContext = ( + prop: RouteProperty, + linkableAnchors: Record, +): LegacyPropertyContext => { + const context: LegacyPropertyContext = { + name: prop.name, + formatLabel: prop.format, + listItemLabel: prop.listItemFormat || undefined, + linkAnchor: linkableAnchors[prop.name], + description: prop.description || undefined, + deprecatedBlock: prop.isDeprecated + ? renderDeprecatedBlock(prop.deprecationMessage) + : undefined, + enumValues: prop.enumValues, + } + + if (prop.discriminatorVariants != null && prop.discriminator != null) { + context.discriminator = prop.discriminator + context.variants = prop.discriminatorVariants.map((variant) => ({ + name: variant.name, + description: variant.description, + properties: variant.properties.map((p, i, all) => ({ + name: p.name, + formatLabel: p.format, + listItemLabel: p.listItemFormat || undefined, + description: p.description || undefined, + enumValues: p.enumValues, + isLast: i === all.length - 1, + })), + })) + } else if (prop.listProperties != null && prop.listProperties.length > 0) { + context.childObjectProperties = prop.listProperties.map((p) => ({ + name: p.name, + formatLabel: p.format, + description: p.description || undefined, + enumValues: p.enumValues, + })) + } else if ( + prop.objectProperties != null && + prop.objectProperties.length > 0 + ) { + context.childPropertiesBlock = renderChildPropertiesBlock( + prop.objectProperties, + ) + } + + return context +} + +// --- Page builders ---------------------------------------------------------- + +/** Sample filter ported from api-route.ts resourceSampleFilter (fuzzy title + * match on the singularized group key). */ +const resourceSampleFilter = + ({ + include, + exclude, + }: { + include?: string[] | undefined + exclude?: string[] | undefined + }) => + ({ title }: { title: string }): boolean => { + if (include != null) { + return include.some((x) => + title.toLowerCase().includes(x.split('_')[0]?.slice(0, -1) ?? ''), + ) + } + if (exclude != null) { + return !exclude.some((x) => + title.toLowerCase().includes(x.split('_')[0]?.slice(0, -1) ?? ''), + ) + } + return true + } + +const buildEndpoints = ( + blueprint: Blueprint, + routePath: string, +): LegacyObjectPageLayoutContext['endpoints'] => { + const route = blueprint.routes.find((r) => r.path === routePath) + if (route == null) return [] + return route.endpoints + .filter( + (endpoint: Endpoint) => + !endpoint.isUndocumented && endpoint.title.length !== 0, + ) + .map((endpoint) => ({ + path: endpoint.path, + name: endpoint.path.split('/').filter(Boolean).pop() ?? endpoint.path, + description: getFirstParagraph(endpoint.description.trim()), + })) +} + +/** + * The /locks and /thermostats pages: a group-filtered legacy view of the + * `device` resource, mirroring the GitBook route README for the same route. + */ +export function setLegacyObjectPageLayoutContext( + blueprint: Blueprint, + resource: Resource, + routePath: string, + meta: ObjectPageEntry, +): LegacyObjectPageLayoutContext { + const groupOptions = { + include: meta.include_groups, + exclude: meta.exclude_groups, + } + + const allProperties = (resource.properties as Property[]).filter( + ({ isUndocumented }) => !isUndocumented, + ) + const properties = allProperties.filter(({ name }) => name !== 'properties') + const legacyProperty = allProperties.find(({ name }) => name === 'properties') + + const errorsProp = (resource.properties as Property[]).find( + (p) => p.name === 'errors', + ) + const warningsProp = (resource.properties as Property[]).find( + (p) => p.name === 'warnings', + ) + const errorEntries = groupVariants( + errorsProp, + groupOptions, + resource.resourceType, + [resource.resourceType], + ).map(({ name, description }) => ({ code: name, description })) + const warningEntries = groupVariants( + warningsProp, + groupOptions, + resource.resourceType, + [resource.resourceType], + ).map(({ name, description }) => ({ code: name, description })) + + const linkableAnchors: Record = { + errors: errorEntries.length > 0 ? '#errors' : undefined, + warnings: warningEntries.length > 0 ? '#warnings' : undefined, + } + + const topProperties = groupProperties( + properties, + (resource.propertyGroups ?? []) as PropertyGroup[], + groupOptions, + ).map((p) => toLegacyPropertyContext(p, linkableAnchors)) + + const propertySections: LegacyObjectPageLayoutContext['propertySections'] = [ + { heading: 'Properties', properties: topProperties }, + ] + + if (legacyProperty != null && legacyProperty.format === 'object') { + const legacyChildren = groupProperties( + legacyProperty.properties.filter( + ({ isUndocumented }) => !isUndocumented, + ) as Property[], + ((legacyProperty as { propertyGroups?: PropertyGroup[] }) + .propertyGroups ?? []) as PropertyGroup[], + groupOptions, + ).map((p) => toLegacyPropertyContext(p, {})) + if (legacyChildren.length > 0) { + propertySections.push({ + heading: `${resource.resourceType}.properties`, + properties: legacyChildren, + }) + } + } + + const samples = resource.resourceSamples.filter( + resourceSampleFilter(groupOptions), + ) + + return { + layout: 'legacy-object.hbs', + contents: Buffer.from(''), + title: meta.title, + description: meta.description, + heading: meta.heading ?? `The ${resource.resourceType} Object`, + intro: meta.intro?.trim() ?? resource.description, + tabs: samples.map((sample) => { + const jsonSample = Object.entries(sample.resource).find( + ([k]) => k === 'seam_cli', + )?.[1] as { resource_data: string } | undefined + if (jsonSample == null) { + throw new Error( + `Missing seam_cli for ${sample.resource_type} resource sample: ${sample.title}`, + ) + } + return { + title: sample.title, + description: sample.description, + json: jsonSample.resource_data.trim(), + } + }), + examples: samples.map((sample) => ({ + title: sample.title, + json: JSON.stringify(sample.properties, null, 2), + })), + propertySections, + errorEntries, + warningEntries, + endpoints: buildEndpoints(blueprint, routePath), + } +} + +/** + * The /action_attempts legacy page. Mirrors processActionAttemptResource in + * codegen/lib/layout/api-route.ts: five hardcoded fields in that order, with + * the action_type enum built from the documented, non-deprecated attempts. + */ +export function setLegacyActionAttemptPageLayoutContext( + blueprint: Blueprint, + routePath: string, + meta: ObjectPageEntry, +): LegacyObjectPageLayoutContext { + const actionTypes = blueprint.actionAttempts + .filter( + ({ isDeprecated, isUndocumented }) => !(isUndocumented || isDeprecated), + ) + .filter( + ({ actionAttemptType }) => + !['CREATE', 'DELETE', 'UPDATE', 'SYNC'].includes( + actionAttemptType.split('_')[0] ?? '', + ), + ) + .map(({ actionAttemptType }) => actionAttemptType) + + const field = ( + name: string, + formatLabel: string, + description: string, + enumValues?: string[], + ): LegacyPropertyContext => ({ + name, + formatLabel, + description, + enumValues, + }) + + return { + layout: 'legacy-object.hbs', + contents: Buffer.from(''), + title: meta.title, + description: meta.description, + heading: meta.heading ?? 'The action_attempt Object', + intro: + meta.intro?.trim() ?? + 'Represents an attempt to perform an action against a device.', + tabs: [], + examples: [ + { + title: 'Action Attempt', + json: JSON.stringify( + { + action_attempt_id: '3f2b1c8d-1b5e-4f8c-9c7d-9a8b7c6d5e4f', + action_type: 'LOCK_DOOR', + error: null, + result: {}, + status: 'success', + }, + null, + 2, + ), + }, + ], + propertySections: [ + { + heading: 'Properties', + properties: [ + field('action_attempt_id', 'UUID', 'ID of the action attempt.'), + field('status', 'Enum', 'Status of the action attempt.', [ + 'pending', + 'success', + 'error', + ]), + field( + 'action_type', + 'Enum', + 'Type of the action attempt.', + actionTypes, + ), + field( + 'error', + 'Object', + 'Error associated with the action attempt. Null for pending and successful action attempts.', + ), + field( + 'result', + 'Object', + 'Result of the action attempt. Null for pending and errored action attempts.', + ), + ], + }, + ], + errorEntries: [], + warningEntries: [], + endpoints: buildEndpoints(blueprint, routePath), + } +} diff --git a/mintlify-codegen/lib/layout/namespace-page.ts b/mintlify-codegen/lib/layout/namespace-page.ts new file mode 100644 index 000000000..cdfc79c6e --- /dev/null +++ b/mintlify-codegen/lib/layout/namespace-page.ts @@ -0,0 +1,51 @@ +import type { Blueprint } from '@seamapi/blueprint' + +import type { ObjectPageEntry } from '../object-page-metadata.js' + +export interface NamespacePageLayoutContext { + layout: string + contents: Buffer + title: string + description: string + intro: string + resourcesIntro?: string | undefined + resources: Array<{ + resourceType: string + routePath: string + description: string + }> +} + +/** + * Build the layout context for a namespace overview page (`namespace: true` + * in object-pages.yaml, e.g. /acs): the authored intro followed by every + * documented resource under the namespace with its blueprint description, + * linking to the resource's object page. + */ +export function setNamespacePageLayoutContext( + blueprint: Blueprint, + routePath: string, + meta: ObjectPageEntry, +): NamespacePageLayoutContext { + const resources = blueprint.resources + .filter( + (resource) => + resource.routePath.startsWith(`${routePath}/`) && + !resource.isUndocumented, + ) + .map((resource) => ({ + resourceType: resource.resourceType, + routePath: resource.routePath, + description: resource.description, + })) + + return { + layout: 'namespace.hbs', + contents: Buffer.from(''), + title: meta.title, + description: meta.description, + intro: meta.intro?.trim() ?? '', + resourcesIntro: meta.resources_intro?.trim(), + resources, + } +} diff --git a/mintlify-codegen/lib/layout/object-page.ts b/mintlify-codegen/lib/layout/object-page.ts new file mode 100644 index 000000000..4ca5b0198 --- /dev/null +++ b/mintlify-codegen/lib/layout/object-page.ts @@ -0,0 +1,324 @@ +import type { Blueprint } from '@seamapi/blueprint' + +import { hiddenProperties } from '../config.js' +import { formatTypeBasic } from '../format-type.js' +import type { ObjectPageEntry } from '../object-page-metadata.js' + +type Resource = Blueprint['resources'][number] + +interface BlueprintProperty { + name: string + description: string + format: string + jsonType: string + isDeprecated: boolean + isUndocumented: boolean + properties?: BlueprintProperty[] + propertyGroups?: Array<{ name: string; propertyGroupKey: string }> + propertyGroupKey?: string | null +} + +export interface PropertyFieldContext { + name: string + type: string + deprecated: boolean + description: string + children: PropertyFieldContext[] + // Renders an `Enum values` accordion after the description. Set on the Event + // object page's `event_type` field (the full event-type list) and on the + // synthesized action_attempt enums. + enumValues?: string[] +} + +interface ResourceSectionContext { + resourceType: string + heading: string + description: string + samples: Array<{ title: string; json: string }> + fields: PropertyFieldContext[] + subGroups: Array<{ heading: string; fields: PropertyFieldContext[] }> +} + +export interface ObjectPageLayoutContext { + layout: string + contents: Buffer + title: string + description: string + resources: ResourceSectionContext[] +} + +interface GroupOptions { + include?: string[] | undefined + exclude?: string[] | undefined +} + +/** Property-group filter following the codegen/data/paths.yaml idiom: the + * ungrouped properties are always kept; `include` keeps only the listed + * groups; `exclude` drops the listed groups. */ +const keepGroup = + ({ include, exclude }: GroupOptions) => + (propertyGroupKey: string | null | undefined): boolean => { + if (propertyGroupKey == null) return true + if (include != null && !include.includes(propertyGroupKey)) return false + if (exclude != null && exclude.includes(propertyGroupKey)) return false + return true + } + +/** Resource-sample filter for group-filtered pages, ported verbatim from + * codegen/lib/layout/api-route.ts `resourceSampleFilter` (fuzzy title match on + * the singularized group key). */ +const resourceSampleFilter = + ({ include, exclude }: GroupOptions) => + ({ title }: { title: string }): boolean => { + if (include != null) { + return include.some((x) => + title.toLowerCase().includes(x.split('_')[0]?.slice(0, -1) ?? ''), + ) + } + if (exclude != null) { + return !exclude.some((x) => + title.toLowerCase().includes(x.split('_')[0]?.slice(0, -1) ?? ''), + ) + } + return true + } + +function shouldHide(resourceType: string, prop: BlueprintProperty): boolean { + if (prop.isUndocumented) return true + return hiddenProperties[resourceType]?.has(prop.name) ?? false +} + +function buildField( + prop: BlueprintProperty, + resourceType: string, +): PropertyFieldContext { + const field: PropertyFieldContext = { + name: prop.name, + type: formatTypeBasic(prop.format, prop.jsonType), + deprecated: prop.isDeprecated, + description: prop.description || `The ${prop.name.replace(/_/g, ' ')}.`, + children: [], + } + + if ( + prop.format === 'object' && + prop.properties && + prop.properties.length > 0 + ) { + field.children = prop.properties + .filter((c) => !shouldHide(resourceType, c)) + .map((c) => buildField(c, resourceType)) + } + + return field +} + +/** + * Build the layout context for a resource section: the ungrouped properties + * first, then the grouped properties in group insertion order (both rendered + * as one `## Properties` run of fields), then the nested `properties` + * sub-group sections (e.g. Hardware, Locks on the device resource). + * + * `groupOptions` narrows a shared resource to a sub-category view (e.g. the + * /locks page keeps only the `locks` and `access_codes` property groups and + * samples of the `device` resource). + */ +function buildResourceSection( + resource: Resource, + eventTypes: string[] | undefined, + meta: ObjectPageEntry, +): ResourceSectionContext { + const { resourceType } = resource + const properties = resource.properties as unknown as BlueprintProperty[] + const groupOptions: GroupOptions = { + include: meta.include_groups, + exclude: meta.exclude_groups, + } + const keep = keepGroup(groupOptions) + + const ungrouped = properties.filter( + (p) => !shouldHide(resourceType, p) && !p.propertyGroupKey, + ) + const grouped = new Map() + for (const p of properties) { + if (shouldHide(resourceType, p) || !p.propertyGroupKey) continue + if (!keep(p.propertyGroupKey)) continue + const existing = grouped.get(p.propertyGroupKey) + if (existing) existing.push(p) + else grouped.set(p.propertyGroupKey, [p]) + } + + const fields = [...ungrouped, ...[...grouped.values()].flat()].map((p) => + buildField(p, resourceType), + ) + + // The Event object page's `event_type` field carries the full enum. + if (eventTypes != null) { + const eventTypeField = fields.find((f) => f.name === 'event_type') + if (eventTypeField != null) { + eventTypeField.enumValues = eventTypes + eventTypeField.description = 'The event type.' + } + } + + const subGroups: ResourceSectionContext['subGroups'] = [] + const propsField = properties.find( + (p) => p.name === 'properties' && p.format === 'object', + ) + if (propsField?.properties && propsField.properties.length > 0) { + const groups = (propsField.propertyGroups ?? []).filter((g) => + keep(g.propertyGroupKey), + ) + if (groups.length > 0) { + for (const group of groups) { + const groupProps = propsField.properties.filter( + (p) => + p.propertyGroupKey === group.propertyGroupKey && + !shouldHide(resourceType, p), + ) + if (groupProps.length === 0) continue + subGroups.push({ + heading: group.name, + fields: groupProps.map((p) => buildField(p, resourceType)), + }) + } + } else if ((propsField.propertyGroups ?? []).length === 0) { + const visibleChildren = propsField.properties.filter( + (p) => !shouldHide(resourceType, p), + ) + if (visibleChildren.length > 0) { + subGroups.push({ + heading: `${resourceType}.properties`, + fields: visibleChildren.map((p) => buildField(p, resourceType)), + }) + } + } + } + + return { + resourceType, + heading: meta.heading ?? `The ${resourceType} Object`, + description: meta.intro?.trim() ?? resource.description, + samples: resource.resourceSamples + .filter(resourceSampleFilter(groupOptions)) + .map((s) => ({ + title: s.title, + json: JSON.stringify(s.properties, null, 2), + })), + fields, + subGroups, + } +} + +export function setObjectPageLayoutContext( + resources: Resource[], + meta: ObjectPageEntry, + eventTypes: string[] | undefined, +): ObjectPageLayoutContext { + return { + layout: 'object.hbs', + contents: Buffer.from(''), + title: meta.title, + description: meta.description, + resources: resources.map((r) => buildResourceSection(r, eventTypes, meta)), + } +} + +/** + * The action_attempt object page. The blueprint has no action_attempt + * resource — it models action attempts as `blueprint.actionAttempts` variants — + * so the section is synthesized, mirroring `processActionAttemptResource` in + * codegen/lib/layout/api-route.ts (including its hardcoded core properties and + * the `action_type` enum built from the non-deprecated, documented attempts). + */ +export function setActionAttemptPageLayoutContext( + blueprint: Blueprint, + meta: ObjectPageEntry, +): ObjectPageLayoutContext { + const actionTypes = blueprint.actionAttempts + .filter( + ({ isDeprecated, isUndocumented }) => !(isUndocumented || isDeprecated), + ) + .filter( + ({ actionAttemptType }) => + !['CREATE', 'DELETE', 'UPDATE', 'SYNC'].includes( + actionAttemptType.split('_')[0] ?? '', + ), + ) + .map(({ actionAttemptType }) => actionAttemptType) + + const field = ( + name: string, + type: string, + description: string, + enumValues?: string[], + ): PropertyFieldContext => ({ + name, + type, + deprecated: false, + description, + children: [], + ...(enumValues == null ? {} : { enumValues }), + }) + + return { + layout: 'object.hbs', + contents: Buffer.from(''), + title: meta.title, + description: meta.description, + resources: [ + { + resourceType: 'action_attempt', + heading: meta.heading ?? 'The action_attempt Object', + description: + meta.intro?.trim() ?? + 'Represents an attempt to perform an action against a device.', + samples: [ + { + title: 'Action Attempt', + json: JSON.stringify( + { + action_attempt_id: '3f2b1c8d-1b5e-4f8c-9c7d-9a8b7c6d5e4f', + action_type: 'LOCK_DOOR', + error: null, + result: {}, + status: 'success', + }, + null, + 2, + ), + }, + ], + fields: [ + field( + 'action_attempt_id', + 'String (UUID)', + 'ID of the action attempt.', + ), + field('status', 'Enum (String)', 'Status of the action attempt.', [ + 'pending', + 'success', + 'error', + ]), + field( + 'action_type', + 'Enum (String)', + 'Type of the action attempt.', + actionTypes, + ), + field( + 'error', + 'Object', + 'Error associated with the action attempt. Null for pending and successful action attempts.', + ), + field( + 'result', + 'Object', + 'Result of the action attempt. Null for pending and errored action attempts.', + ), + ], + subGroups: [], + }, + ], + } +} diff --git a/mintlify-codegen/load-data.ts b/mintlify-codegen/lib/missing-samples.ts similarity index 52% rename from mintlify-codegen/load-data.ts rename to mintlify-codegen/lib/missing-samples.ts index 5fe2868e9..c829b148c 100644 --- a/mintlify-codegen/load-data.ts +++ b/mintlify-codegen/lib/missing-samples.ts @@ -1,50 +1,6 @@ /* eslint-disable no-console */ -import { readdir, readFile } from 'node:fs/promises' -import { join } from 'node:path' - -import { type Blueprint, createBlueprint } from '@seamapi/blueprint' import * as types from '@seamapi/types/connect' -// @ts-expect-error js-yaml has no type declarations -import jsYaml from 'js-yaml' - -import { formatCode } from '../codegen/lib/format-code.js' - -const yamlParse = (content: string): any => jsYaml.load(content) - -interface PathMetadataEntry { - title: string - description?: string - overview?: string - alpha?: string -} - -export type PathMetadata = Record - -/** - * Load all YAML arrays from a directory, concatenating them into a flat array. - * Replicates what @metalsmith/metadata does for directory sources. - */ -async function loadYamlArrayDir(dir: string): Promise { - const files = await readdir(dir) - const arrays = await Promise.all( - files - .filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')) - .sort() - .map(async (f) => { - const content = await readFile(join(dir, f), 'utf8') - return yamlParse(content) as unknown[] - }), - ) - return arrays.flat() -} - -/** - * Load path metadata from paths.yaml. - */ -async function loadPathMetadata(filepath: string): Promise { - const content = await readFile(filepath, 'utf8') - return yamlParse(content) as PathMetadata -} +import type Metalsmith from 'metalsmith' interface DiscriminatorConfig { resourceType: string @@ -137,39 +93,18 @@ function generateMissingSamples(existingSamples: any[]): any[] { } /** - * Create a Blueprint with code samples from @seamapi/types and YAML definitions. + * Metalsmith plugin: synthesize resource samples for discriminated resources + * (e.g. one `access_method` sample per `mode`) that the hand-written YAML + * definitions don't cover. Runs after `@metalsmith/metadata` and before the + * `blueprint()` plugin, which consumes `metadata.resourceSampleDefinitions`. */ -export async function loadBlueprint( - skipCodeFormat: boolean, -): Promise<{ blueprint: Blueprint; pathMetadata: PathMetadata }> { - const dataDir = join(import.meta.dirname, '..', 'codegen', 'data') - - const [codeSampleDefinitions, resourceSampleDefinitions, pathMetadata] = - await Promise.all([ - loadYamlArrayDir(join(dataDir, 'code-sample-definitions')), - loadYamlArrayDir(join(dataDir, 'resource-sample-definitions')), - loadPathMetadata(join(dataDir, 'paths.yaml')), - ]) - - const autoGenerated = generateMissingSamples( - resourceSampleDefinitions as any[], - ) - const allResourceSamples = [...resourceSampleDefinitions, ...autoGenerated] - - const typesModule = { - ...types, - codeSampleDefinitions, - resourceSampleDefinitions: allResourceSamples, +export const missingSamples: Metalsmith.Plugin = (_files, metalsmith) => { + const metadata = metalsmith.metadata() as { + resourceSampleDefinitions?: unknown[] } - - const blueprint = await createBlueprint( - typesModule as any, - skipCodeFormat ? {} : { formatCode }, - ) - - return { blueprint, pathMetadata } -} - -export function getRawOpenApiSpec(): any { - return JSON.parse(JSON.stringify(types.openapi)) + const existing = metadata.resourceSampleDefinitions ?? [] + metadata.resourceSampleDefinitions = [ + ...existing, + ...generateMissingSamples(existing as any[]), + ] } diff --git a/mintlify-codegen/lib/object-page-metadata.ts b/mintlify-codegen/lib/object-page-metadata.ts new file mode 100644 index 000000000..edd244784 --- /dev/null +++ b/mintlify-codegen/lib/object-page-metadata.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' + +/** + * Schema for data/object-pages.yaml: per-route metadata for the API-reference + * object pages, keyed by API route path (e.g. `/access_codes`). Mirrors the + * codegen/lib/path-metadata.ts pattern (including its `include_groups` / + * `exclude_groups` group-filtering idiom). + * + * Every object page is generated; the entry decides the page kind: + * - default: a resource object page. The backing resources come from the + * blueprint resources at the route (those with samples), or from + * `resource_type` when the route has no resource of its own (e.g. /locks + * is a filtered view of the shared `device` resource). + * - `endpoints: true`: an endpoint-listing page built from the blueprint + * route (simulate/ and unmanaged/ routes). + * - `namespace: true`: a resource-listing page for a namespace route + * (e.g. /acs). + * + * `title` also supplies the noun for generated events/errors page titles + * ("The Device Object" -> "Device"). `intro` is authored MDX rendered in + * place of the blueprint resource description (or above a namespace's + * resource list). + */ +export const ObjectPageMetadataSchema = z.record( + z.string().startsWith('/'), + z.object({ + title: z.string().trim().min(1), + description: z.string().trim().min(1), + endpoints: z.literal(true).optional(), + namespace: z.literal(true).optional(), + // Renders the page in the legacy (GitBook-migrated) presentation the + // committed page had, regenerated from the blueprint. TODO: modernize + // these pages to the standard object-page format and drop this flag. + legacy: z.literal(true).optional(), + resource_type: z.string().trim().min(1).optional(), + include_groups: z.array(z.string()).optional(), + exclude_groups: z.array(z.string()).optional(), + heading: z.string().trim().min(1).optional(), + intro: z.string().trim().min(1).optional(), + // Authored line between a namespace page's `## Resources` heading and its + // resource list. + resources_intro: z.string().trim().min(1).optional(), + }), +) + +export type ObjectPageMetadata = z.infer +export type ObjectPageEntry = ObjectPageMetadata[string] diff --git a/mintlify-codegen/lib/openapi.ts b/mintlify-codegen/lib/openapi.ts new file mode 100644 index 000000000..174ff3ff6 --- /dev/null +++ b/mintlify-codegen/lib/openapi.ts @@ -0,0 +1,64 @@ +/* eslint-disable no-console */ +import type { Blueprint } from '@seamapi/blueprint' +import * as types from '@seamapi/types/connect' +import type Metalsmith from 'metalsmith' + +import { + type PathMetadata, + transformSpec, + type TransformStats, +} from './transform-spec.js' + +/** Pipeline state shared between the mintlify plugins via Metalsmith metadata. */ +export interface MintlifyMetadata { + specPaths: Set + stats: TransformStats + eventPageRoutes: string[] + errorPageRoutes: string[] +} + +/** + * Metalsmith plugin: enrich the raw OpenAPI spec from `@seamapi/types` with + * blueprint code samples and Mintlify extensions (see transform-spec.ts) and + * emit it as `openapi.json`. Mintlify renders the endpoint pages natively from + * this spec — endpoint pages are deliberately not templated. + */ +export const openapi: Metalsmith.Plugin = (files, metalsmith) => { + const metadata = metalsmith.metadata() as { + blueprint: Blueprint + pathMetadata: PathMetadata + mintlify?: MintlifyMetadata + } + + console.log('Transforming OpenAPI spec...') + const rawSpec = JSON.parse(JSON.stringify(types.openapi)) + const { spec, stats } = transformSpec( + rawSpec, + metadata.blueprint, + metadata.pathMetadata, + ) + + const orderedSpec = { + openapi: spec.openapi, + info: spec.info, + servers: spec.servers, + components: spec.components, + ...(spec.tags ? { tags: spec.tags } : {}), + paths: spec.paths, + } + files['openapi.json'] = { + contents: Buffer.from(JSON.stringify(orderedSpec, null, 2)), + } as (typeof files)[string] + + const size = JSON.stringify(orderedSpec).length + console.log( + ` Wrote openapi.json: ${stats.totalEndpoints} endpoints (${(size / 1024).toFixed(0)}KB)`, + ) + + metadata.mintlify = { + specPaths: new Set(Object.keys(spec.paths)), + stats, + eventPageRoutes: [], + errorPageRoutes: [], + } +} diff --git a/mintlify-codegen/lib/postprocess.ts b/mintlify-codegen/lib/postprocess.ts new file mode 100644 index 000000000..059551ba6 --- /dev/null +++ b/mintlify-codegen/lib/postprocess.ts @@ -0,0 +1,54 @@ +/* eslint-disable no-console */ +import type Metalsmith from 'metalsmith' + +import { canonicalizeLinks } from './canonicalize-links.js' + +/** + * Metalsmith plugin: canonicalize docs links in the generated output + * (openapi.json plus every API `.mdx` page). Guards against two classes of + * upstream @seamapi/types regression: + * 1. legacy `/latest` reappearing in `docs.seam.co/latest/...` links + * (the site serves at the root; a `/latest/:path*` redirect handles old + * inbound URLs). See DOC-206 / DOC-199. + * 2. links that target a path which docs.json redirects elsewhere — these + * still resolve in the browser but cost an extra hop and dodge anchor + * validation. We rewrite them to their final canonical destination so + * generated content links directly. See DOC-210. + * This is self-healing: every `npm run generate` re-applies it, so source + * descriptions can lag without leaving stale links in the published docs. + * + * Runs after the docs-json plugin so the redirect map reflects the final + * docs.json, and after layouts so it sees the rendered pages. + */ +export const postprocess: Metalsmith.Plugin = (files) => { + console.log('Canonicalizing docs links in generated output...') + + const docsJsonFile = files['docs.json'] + if (docsJsonFile == null) throw new Error('docs.json was not ingested') + const docsJson = JSON.parse(docsJsonFile.contents.toString()) as { + redirects?: Array<{ source: string; destination: string }> + } + const redirects = new Map() + for (const r of docsJson.redirects ?? []) { + redirects.set(r.source, r.destination) + } + + const targets = Object.keys(files).filter( + (key) => + key === 'openapi.json' || + (key.startsWith('api/') && key.endsWith('.mdx')), + ) + + let count = 0 + for (const key of targets) { + const file = files[key] + if (file == null) continue + const content = file.contents.toString() + const replaced = canonicalizeLinks(content, redirects) + if (replaced !== content) { + file.contents = Buffer.from(replaced) + count++ + } + } + console.log(` Canonicalized links in ${count} generated file(s)`) +} diff --git a/mintlify-codegen/lib/reference.ts b/mintlify-codegen/lib/reference.ts new file mode 100644 index 000000000..ce3df514d --- /dev/null +++ b/mintlify-codegen/lib/reference.ts @@ -0,0 +1,402 @@ +/* eslint-disable no-console */ +import type { Blueprint } from '@seamapi/blueprint' +import type Metalsmith from 'metalsmith' + +import { + commonDeviceErrorsRoute, + deviceSubcategoryErrorRoutes, + eventObjectRoute, + inheritedErrorGroupAllowlist, +} from './config.js' +import { setEndpointsPageLayoutContext } from './layout/endpoints-page.js' +import { + type ErrorsPageOptions, + groupCodes, + isDiscriminatedListProperty, + type Property, + setErrorsPageLayoutContext, + variantGroupProp, +} from './layout/errors-page.js' +import { + groupEventsByRoutePath, + setEventsPageLayoutContext, +} from './layout/events-page.js' +import { + setLegacyActionAttemptPageLayoutContext, + setLegacyObjectPageLayoutContext, +} from './layout/legacy-object-page.js' +import { setNamespacePageLayoutContext } from './layout/namespace-page.js' +import { + setActionAttemptPageLayoutContext, + setObjectPageLayoutContext, +} from './layout/object-page.js' +import { + type ObjectPageMetadata, + ObjectPageMetadataSchema, +} from './object-page-metadata.js' +import type { MintlifyMetadata } from './openapi.js' +import { resourceNoun } from './text.js' + +type Resource = Blueprint['resources'][number] + +const objectPageKey = (routePath: string): string => + `api${routePath}/object.mdx` +const eventsPageKey = (routePath: string): string => + `api${routePath}/events.mdx` +const errorsPageKey = (routePath: string): string => + `api${routePath}/errors.mdx` + +/** + * Metalsmith plugin: create the generated API-reference pages — one object page + * per resource route, one events page per event-emitting route, and one + * errors/warnings page per resource with documented codes — as virtual files + * with a layout and a layout context. Handlebars rendering happens in the + * later `@metalsmith/layouts` step. + * + * Also records which routes received events/errors pages in + * `metadata.mintlify` so the docs-json plugin can wire them into the sidebar. + */ +export const reference: Metalsmith.Plugin = (files, metalsmith) => { + const metadata = metalsmith.metadata() as { + blueprint: Blueprint + objectPages: unknown + mintlify: MintlifyMetadata + } + const { blueprint, mintlify } = metadata + const objectPages = ObjectPageMetadataSchema.parse(metadata.objectPages) + + const nounFor = (routePath: string): string => + resourceNoun(objectPages[routePath]?.title, routePath) + + updateObjectPages(files, blueprint, objectPages) + updateEventPages(files, blueprint, objectPages, nounFor, mintlify) + updateErrorPages(files, blueprint, objectPages, nounFor, mintlify) +} + +/** One object page per object-pages.yaml entry. The entry decides the page + * kind: endpoint listing, namespace overview, a view of an explicit + * `resource_type` (optionally group-filtered), or the default — the route's + * blueprint resources that carry samples. */ +function updateObjectPages( + files: Metalsmith.Files, + blueprint: Blueprint, + objectPages: ObjectPageMetadata, +): void { + console.log('Generating object pages...') + + const resourcesByRoute = new Map() + for (const resource of blueprint.resources) { + if (resource.resourceSamples.length === 0) continue + const existing = resourcesByRoute.get(resource.routePath) ?? [] + existing.push(resource) + resourcesByRoute.set(resource.routePath, existing) + } + + // The Event object page's `event_type` field lists every event type. + const eventTypes = [ + ...new Set( + (blueprint.events ?? []) + .filter((event) => !event.isUndocumented) + .map((event) => event.eventType), + ), + ] + + const buildPage = ( + routePath: string, + meta: ObjectPageMetadata[string], + ): object | null => { + if (meta.endpoints) { + const context = setEndpointsPageLayoutContext(blueprint, routePath, meta) + if (context == null) { + console.log( + ` WARNING: no blueprint route with documented endpoints for ` + + `${routePath} — skipping its endpoint-listing page.`, + ) + } + return context + } + + if (meta.namespace) { + return setNamespacePageLayoutContext(blueprint, routePath, meta) + } + + if (meta.legacy) { + if (meta.resource_type === 'action_attempt') { + // The blueprint has no action_attempt resource; the page is synthesized. + return setLegacyActionAttemptPageLayoutContext( + blueprint, + routePath, + meta, + ) + } + const resource = blueprint.resources.find( + (r) => r.resourceType === meta.resource_type, + ) + if (resource == null) { + console.log( + ` WARNING: no blueprint resource backs the legacy object page ` + + `for ${routePath} — skipping. Fix its object-pages.yaml entry.`, + ) + return null + } + return setLegacyObjectPageLayoutContext( + blueprint, + resource, + routePath, + meta, + ) + } + + if (meta.resource_type === 'action_attempt') { + // The blueprint has no action_attempt resource; the page is synthesized. + return setActionAttemptPageLayoutContext(blueprint, meta) + } + + const resources = + meta.resource_type == null + ? resourcesByRoute.get(routePath) + : blueprint.resources.filter( + (r) => r.resourceType === meta.resource_type, + ) + if (resources == null || resources.length === 0) { + console.log( + ` WARNING: no blueprint resources back the object page for ` + + `${routePath} — skipping. Fix its object-pages.yaml entry.`, + ) + return null + } + return setObjectPageLayoutContext( + resources, + meta, + routePath === eventObjectRoute ? eventTypes : undefined, + ) + } + + const generated: string[] = [] + for (const [routePath, meta] of Object.entries(objectPages)) { + const context = buildPage(routePath, meta) + if (context == null) continue + files[objectPageKey(routePath)] = context as Metalsmith.Files[string] + generated.push(routePath) + } + + // Surface blueprint routes whose resources have samples but no object page. + for (const [routePath, resources] of resourcesByRoute) { + if (objectPages[routePath] != null) continue + if (resources.every((r) => r.isUndocumented)) continue + console.log( + ` WARNING: no object-pages.yaml entry for ${routePath} — its ` + + `resources have samples but no object page is generated. Add an ` + + `entry to mintlify-codegen/data/object-pages.yaml.`, + ) + } + + if (generated.length > 0) { + console.log( + ` Generated ${generated.length} object pages: ${generated.join(', ')}`, + ) + } +} + +/** One events page per route that emits documented events and has an object + * page (i.e. an object-pages.yaml entry). */ +function updateEventPages( + files: Metalsmith.Files, + blueprint: Blueprint, + objectPages: ObjectPageMetadata, + nounFor: (routePath: string) => string, + mintlify: MintlifyMetadata, +): void { + console.log('Updating event documentation...') + + const events = (blueprint.events ?? []).filter( + (event) => !event.isUndocumented, + ) + + for (const [routePath, routeEvents] of groupEventsByRoutePath(events)) { + if (objectPages[routePath] == null) { + // Some event route paths have no object page (e.g. + // /user_identities/enrollment_automations). Skip until a page exists. + console.log(` No object page for events on ${routePath}, skipping`) + continue + } + + files[eventsPageKey(routePath)] = setEventsPageLayoutContext( + routeEvents, + nounFor(routePath), + ) as unknown as Metalsmith.Files[string] + mintlify.eventPageRoutes.push(routePath) + } + + if (mintlify.eventPageRoutes.length > 0) { + console.log( + ` Generated events pages for ${mintlify.eventPageRoutes.length} ` + + `resources: ${mintlify.eventPageRoutes.join(', ')}`, + ) + } +} + +/** One errors/warnings page per resource with documented codes, plus the + * device sub-category pages sourced from the device resource's variant groups. */ +function updateErrorPages( + files: Metalsmith.Files, + blueprint: Blueprint, + objectPages: ObjectPageMetadata, + nounFor: (routePath: string) => string, + mintlify: MintlifyMetadata, +): void { + console.log('Updating error and warning documentation...') + + const routes = mintlify.errorPageRoutes + + // Every documented resource's errors page lives at `/api//errors`, + // so an inherited-error group can link back to the parent it came from. + const errorsHrefByResourceType: Record = {} + for (const resource of blueprint.resources) { + if (resource.isUndocumented) continue + errorsHrefByResourceType[resource.resourceType] = + `/api${resource.routePath}/errors` + } + + const writeErrorPage = ( + routePath: string, + errorsProp: Property | undefined, + warningsProp: Property | undefined, + options: ErrorsPageOptions = {}, + ): void => { + // Defensive against a variant-group key ever coinciding with a real resource + // route: the resource loop writes first, so skip a route already emitted + // rather than clobbering it from the sub-category loop. + if (routes.includes(routePath)) { + console.log(` Errors page for ${routePath} already written, skipping`) + return + } + + const context = setErrorsPageLayoutContext( + nounFor(routePath), + errorsProp, + warningsProp, + options, + ) + if (context == null) return + + if (objectPages[routePath] == null) { + console.log(` No object page for errors on ${routePath}, skipping`) + return + } + + files[errorsPageKey(routePath)] = + context as unknown as Metalsmith.Files[string] + routes.push(routePath) + } + + for (const resource of blueprint.resources) { + if (resource.isUndocumented) continue + writeErrorPage( + resource.routePath, + resource.properties.find((p) => p.name === 'errors') as + | Property + | undefined, + resource.properties.find((p) => p.name === 'warnings') as + | Property + | undefined, + { + inheritanceResourceType: resource.resourceType, + inheritedGroupAllowlist: + inheritedErrorGroupAllowlist[resource.resourceType], + errorsHrefByResourceType, + }, + ) + } + + // Pages for device sub-categories (locks, thermostats, …) that have their own + // docs route but no resource of their own: source each from the matching + // variant group on the device resource. + const device = blueprint.resources.find((r) => r.resourceType === 'device') + if (device != null) { + const errorsProp = device.properties.find((p) => p.name === 'errors') as + | Property + | undefined + const warningsProp = device.properties.find( + (p) => p.name === 'warnings', + ) as Property | undefined + for (const [ + groupKey, + { routePath, linkCommonDeviceErrors }, + ] of Object.entries(deviceSubcategoryErrorRoutes)) { + writeErrorPage( + routePath, + variantGroupProp(errorsProp, groupKey), + variantGroupProp(warningsProp, groupKey), + linkCommonDeviceErrors ? { commonDeviceErrorsRoute } : {}, + ) + } + + // Flag any device sub-category that has its own object+events pages and + // error codes but was never added to the allowlist above. + const documentedResourceRoutes = new Set( + blueprint.resources + .filter((r) => !r.isUndocumented) + .map((r) => r.routePath), + ) + warnUnmappedDeviceSubcategoryGroups( + files, + errorsProp, + warningsProp, + documentedResourceRoutes, + ) + } + + if (routes.length > 0) { + console.log( + ` Generated errors pages for ${routes.length} resources: ${routes.join(', ')}`, + ) + } +} + +/** + * Warn when a device errors/warnings variant group looks like it should have its + * own sub-category errors page but is missing from deviceSubcategoryErrorRoutes. + * + * That allowlist is maintained by hand, so a newly added device sub-category + * (its own object + events pages plus an error/warning variant group) would + * silently produce no errors page until someone edits the map. This surfaces + * that inconsistency at generate time. + * + * A group is flagged only when it (a) has documented codes, (b) has both an + * object page and an events page in the build — the shape a real sub-category + * route has — and (c) is neither already mapped nor itself a documented + * blueprint resource. + */ +function warnUnmappedDeviceSubcategoryGroups( + files: Metalsmith.Files, + errorsProp: Property | undefined, + warningsProp: Property | undefined, + documentedResourceRoutes: Set, +): void { + const groupKeys = new Set() + for (const prop of [errorsProp, warningsProp]) { + if (!isDiscriminatedListProperty(prop)) continue + for (const group of prop.variantGroups) groupKeys.add(group.variantGroupKey) + } + + for (const groupKey of groupKeys) { + if (groupKey in deviceSubcategoryErrorRoutes) continue + if (documentedResourceRoutes.has(`/${groupKey}`)) continue + + const hasCodes = + groupCodes(variantGroupProp(errorsProp, groupKey)).length > 0 || + groupCodes(variantGroupProp(warningsProp, groupKey)).length > 0 + if (!hasCodes) continue + + const hasObject = files[objectPageKey(`/${groupKey}`)] != null + const hasEvents = files[eventsPageKey(`/${groupKey}`)] != null + if (!hasObject || !hasEvents) continue + + console.log( + ` WARNING: device errors/warnings variant group "${groupKey}" has codes ` + + `and an object+events page (/api/${groupKey}) but no dedicated errors ` + + `page. Add it to deviceSubcategoryErrorRoutes in mintlify-codegen/lib/config.ts.`, + ) + } +} diff --git a/mintlify-codegen/lib/report.ts b/mintlify-codegen/lib/report.ts new file mode 100644 index 000000000..1041711ea --- /dev/null +++ b/mintlify-codegen/lib/report.ts @@ -0,0 +1,24 @@ +/* eslint-disable no-console */ +import type Metalsmith from 'metalsmith' + +import type { MintlifyMetadata } from './openapi.js' + +/** Metalsmith plugin: print the end-of-run stats (ported from generate.ts). */ +export const report: Metalsmith.Plugin = (_files, metalsmith) => { + const { stats } = (metalsmith.metadata() as { mintlify: MintlifyMetadata }) + .mintlify + + console.log(`\nDone!`) + console.log(` Removed undocumented paths: ${stats.removedPaths}`) + console.log(` Total documented endpoints: ${stats.totalEndpoints}`) + console.log(` With code samples: ${stats.withCodeSamples}`) + console.log(` With scoped action_attempts: ${stats.withActionAttempts}`) + console.log( + ` Without code samples: ${stats.withoutCodeSamples.length} endpoints`, + ) + if (stats.withoutCodeSamples.length > 0) { + console.log( + ` Missing samples: ${stats.withoutCodeSamples.slice(0, 10).join(', ')}${stats.withoutCodeSamples.length > 10 ? `... and ${stats.withoutCodeSamples.length - 10} more` : ''}`, + ) + } +} diff --git a/mintlify-codegen/lib/sample-value.ts b/mintlify-codegen/lib/sample-value.ts new file mode 100644 index 000000000..c1d82d7c2 --- /dev/null +++ b/mintlify-codegen/lib/sample-value.ts @@ -0,0 +1,95 @@ +import { hasEnumValues } from './format-type.js' + +interface Sampleable { + format: string + values?: Array<{ name: string }> +} + +/** + * Build an illustrative sample value for a property from its format. Values are + * fixed (never random) so generated payloads are stable across runs. Used by + * the errors pages; the events pages use `sampleValueDeep` below. + */ +export function sampleValue(prop: Sampleable): unknown { + if (hasEnumValues(prop) && prop.values.length > 0) { + return prop.values[0]?.name ?? '' + } + switch (prop.format) { + case 'id': + return '00000000-0000-0000-0000-000000000000' + case 'datetime': + return '2025-01-01T00:00:00.000Z' + case 'boolean': + return true + case 'number': + return 0 + case 'list': + return [] + case 'object': + case 'record': + return {} + case 'string': + return '' + default: + return null + } +} + +// Illustrative values for well-known fields that the type system types as +// plain strings, so their `from`/`to` payloads read realistically instead of +// showing `""`. `*_at` string fields are handled as datetimes below. +const sampleStringValues: Record = { + code: '1234', + name: 'My Access Code', +} + +interface DeepSampleable extends Sampleable { + name: string + isUndocumented?: boolean + properties?: DeepSampleable[] +} + +function hasNestedProperties( + prop: DeepSampleable, +): prop is DeepSampleable & { properties: DeepSampleable[] } { + return Array.isArray(prop.properties) && prop.properties.length > 0 +} + +/** + * Event-page variant of `sampleValue`: recurses into a `*_changed` event's + * typed `from`/`to` payloads (free-form records have no `properties` and stay + * `{}`), renders `*_at` string fields as datetimes, and substitutes realistic + * values for well-known string fields. + */ +export function sampleValueDeep(prop: DeepSampleable): unknown { + if (hasEnumValues(prop) && prop.values.length > 0) { + return prop.values[0]?.name ?? '' + } + switch (prop.format) { + case 'id': + return '00000000-0000-0000-0000-000000000000' + case 'datetime': + return '2025-01-01T00:00:00.000Z' + case 'boolean': + return true + case 'number': + return 0 + case 'list': + return [] + case 'object': + case 'record': { + if (!hasNestedProperties(prop)) return {} + const nested: Record = {} + for (const child of prop.properties) { + if (child.isUndocumented) continue + nested[child.name] = sampleValueDeep(child) + } + return nested + } + case 'string': + if (/_at$/.test(prop.name)) return '2025-01-01T00:00:00.000Z' + return sampleStringValues[prop.name] ?? '' + default: + return null + } +} diff --git a/mintlify-codegen/lib/text.ts b/mintlify-codegen/lib/text.ts new file mode 100644 index 000000000..491998e56 --- /dev/null +++ b/mintlify-codegen/lib/text.ts @@ -0,0 +1,41 @@ +/** Indent every non-blank line of `text` by `spaces` spaces. */ +export function indent(text: string, spaces: number): string { + const pad = ' '.repeat(spaces) + return text + .split('\n') + .map((line) => (line.trim() ? pad + line : line)) + .join('\n') +} + +/** Escape single quotes for a single-quoted frontmatter value. */ +export function escapeSingleQuotes(value: string): string { + return value.replace(/'/g, "\\'") +} + +/** + * The display noun for a resource route, from its object page title + * (`The Device Object` -> `Device`), falling back to a humanized route path + * (`/noise_sensors/noise_thresholds` -> `Noise Sensors Noise Thresholds`). + * Feeds the generated events/errors page titles. + */ +export function resourceNoun( + objectTitle: string | undefined, + routePath: string, +): string { + const noun = objectTitle + ?.replace(/^The\s+/, '') + .replace(/\s+Object$/, '') + .trim() + if (noun) return noun + + return routePath + .slice(1) + .split('/') + .map((seg) => + seg + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '), + ) + .join(' ') +} diff --git a/mintlify-codegen/transform-spec.ts b/mintlify-codegen/lib/transform-spec.ts similarity index 98% rename from mintlify-codegen/transform-spec.ts rename to mintlify-codegen/lib/transform-spec.ts index 4233be0d1..d4edb89b1 100644 --- a/mintlify-codegen/transform-spec.ts +++ b/mintlify-codegen/lib/transform-spec.ts @@ -1,7 +1,15 @@ import type { Blueprint, Endpoint, SdkName } from '@seamapi/blueprint' -import { supportedSdkOrder } from '../codegen/lib/code-sample-tab-order.js' -import type { PathMetadata } from './load-data.js' +import { supportedSdkOrder } from '../../codegen/lib/code-sample-tab-order.js' + +export interface PathMetadataEntry { + title: string + description?: string + overview?: string + alpha?: string +} + +export type PathMetadata = Record /** * SDKs to include in the Mintlify API reference. diff --git a/mintlify-codegen/property-fields.ts b/mintlify-codegen/property-fields.ts deleted file mode 100644 index a1705503b..000000000 --- a/mintlify-codegen/property-fields.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Shared helpers for rendering blueprint properties on generated API-reference - * pages (events.ts, errors.ts). Kept format- and value-consistent across pages - * so event, error, and warning properties read identically. - */ - -interface Formattable { - format: string - jsonType?: string -} - -interface Sampleable { - format: string - values?: Array<{ name: string }> -} - -/** - * Map a blueprint property format to the type label used across the API object - * pages (e.g. `id` -> `String (UUID)`, `enum` -> `Enum (String)`). Mirrors - * `formatPropertyType` in generate.ts. - */ -export function formatType(prop: Formattable): string { - switch (prop.format) { - case 'id': - return 'String (UUID)' - case 'datetime': - return 'String (ISO 8601)' - case 'enum': - return 'Enum (String)' - case 'list': - return 'Array' - case 'boolean': - return 'Boolean' - case 'string': - return 'String' - case 'object': - case 'record': - return 'Object' - default: - return prop.jsonType ?? 'String' - } -} - -/** Narrow a property to one that carries enum `values`. Generic so it preserves - * the caller's property type. */ -export function hasEnumValues( - prop: T, -): prop is T & { values: Array<{ name: string }> } { - return ( - 'values' in prop && Array.isArray((prop as { values?: unknown }).values) - ) -} - -/** - * Build an illustrative sample value for a property from its format. Values are - * fixed (never random) so generated payloads are stable across runs; this - * mirrors the value conventions in load-data.ts. - */ -export function sampleValue(prop: Sampleable): unknown { - if (hasEnumValues(prop) && prop.values.length > 0) { - return prop.values[0]?.name ?? '' - } - switch (prop.format) { - case 'id': - return '00000000-0000-0000-0000-000000000000' - case 'datetime': - return '2025-01-01T00:00:00.000Z' - case 'boolean': - return true - case 'number': - return 0 - case 'list': - return [] - case 'object': - case 'record': - return {} - case 'string': - return '' - default: - return null - } -} - -/** Indent every non-blank line of `text` by `spaces` spaces. */ -export function indent(text: string, spaces: number): string { - const pad = ' '.repeat(spaces) - return text - .split('\n') - .map((line) => (line.trim() ? pad + line : line)) - .join('\n') -} diff --git a/mintlify-codegen/smith.ts b/mintlify-codegen/smith.ts new file mode 100644 index 000000000..0967f62b4 --- /dev/null +++ b/mintlify-codegen/smith.ts @@ -0,0 +1,84 @@ +/* eslint-disable no-console */ +import { dirname } from 'node:path' +import { env } from 'node:process' +import { fileURLToPath } from 'node:url' + +import layouts from '@metalsmith/layouts' +import metadata from '@metalsmith/metadata' +import { blueprint, getHandlebarsPartials } from '@seamapi/smith' +import * as types from '@seamapi/types/connect' +import { deleteAsync } from 'del' +import Metalsmith from 'metalsmith' + +import { formatCode } from '../codegen/lib/format-code.js' +import { + docsJson, + helpers, + ingest, + missingSamples, + openapi, + postprocess, + reference, + report, +} from './lib/index.js' + +// The Metalsmith build is rooted at the repo root (not mintlify-codegen/): +// @metalsmith/metadata resolves entries with micromatch, which cannot match +// `../` segments, and the pipeline shares codegen/data with the GitBook +// pipeline. +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))) + +console.log('Mintlify OpenAPI codegen starting...') +if (env['SKIP_CODE_FORMAT'] != null) { + console.log(' SKIP_CODE_FORMAT is set — skipping code formatting') +} + +// The api/ tree is wholly pipeline-owned: generated object/events/errors pages +// plus the static pages in ./source. Pre-clean it (like codegen/smith.ts does +// for docs/api-reference) so pages for removed resources don't linger. +await deleteAsync([`${rootDir}/mintlify-docs/api/**`]) + +const partials = await getHandlebarsPartials( + `${rootDir}/mintlify-codegen/layouts/partials`, +) + +Metalsmith(rootDir) + .source('./mintlify-codegen/source') + .destination('./mintlify-docs') + .clean(false) + .frontmatter(false) + .use( + metadata({ + codeSampleDefinitions: './codegen/data/code-sample-definitions', + resourceSampleDefinitions: './codegen/data/resource-sample-definitions', + pathMetadata: './codegen/data/paths.yaml', + objectPages: './mintlify-codegen/data/object-pages.yaml', + }), + ) + .use(missingSamples) + .use( + blueprint({ + types, + formatCode, + skipCodeFormat: env['SKIP_CODE_FORMAT'] != null, + }), + ) + .use(ingest) + .use(openapi) + .use(reference) + .use(docsJson) + .use( + layouts({ + directory: 'mintlify-codegen/layouts', + engineOptions: { + noEscape: true, + helpers, + partials, + }, + }), + ) + .use(postprocess) + .use(report) + .build((err) => { + if (err != null) throw err + }) diff --git a/mintlify-codegen/source/api/authentication.mdx b/mintlify-codegen/source/api/authentication.mdx new file mode 100644 index 000000000..4caca5261 --- /dev/null +++ b/mintlify-codegen/source/api/authentication.mdx @@ -0,0 +1,188 @@ +--- +title: 'Authentication' +description: 'Learn how to authenticate with the Seam API by exporting your API key as an environment variable that the Seam client libraries pick up automatically.' +--- + +Export your API key as an environment variable. Seam client libraries automatically pick up this exported key. For example: + +``` +$ export SEAM_API_KEY=seam_test2bMS_94SrGUXuNR2JmJkjtvBQDg5c +``` + +Next, run the following code to confirm that you are correctly authenticated: + + + + +**Code:** + +```javascript +import { Seam } from 'seam' + +const seam = new Seam() // Seam automatically uses your exported SEAM_API_KEY. + +const checkAuth = async () => { + const workspace = await seam.workspaces.get() + console.log(workspace) +} + +checkAuth() +``` + +**Output:** + +```json +{ + workspace_id: '00000000-0000-0000-0000-000000000000', + name: 'Sandbox', + company_name: 'Acme', + connect_partner_name: 'Acme', + is_sandbox": true +} +``` + + + + + +**Code:** + +```bash +curl -X 'POST' \ + 'https://connect.getseam.com/workspaces/get' \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${SEAM_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d '{}' +``` + +**Output:** + +```json +{ + "workspace": { + "workspace_id": "00000000-0000-0000-0000-000000000000", + "name": "Sandbox", + "company_name": "Acme", + "connect_partner_name": "Acme", + "is_sandbox": true + }, + "ok": true +} +``` + + + + + +**Code:** + +```python +from seam import Seam + +seam = Seam() # Seam automatically uses your exported SEAM_API_KEY. + +workspace = seam.workspaces.get() +pprint(workspace) +``` + +**Output:** + +``` +Workspace( + workspace_id='00000000-0000-0000-0000-000000000000', + name='Sandbox', + company_name='Acme', + connect_partner_name='Acme', + is_sandbox=True +) +``` + + + + + +**Code:** + +```ruby +require "seam" + +seam = Seam.new() # Seam automatically uses your exported SEAM_API_KEY. + +workspace = seam.workspaces.get() + +puts workspace.inspect +``` + +**Output:** + +``` +< + Seam::Workspace:0x0070328 + workspace_id="00000000-0000-0000-0000-000000000000" + name="Sandbox" + company_name="Acme" + connect_partner_name="Acme" + is_sandbox=true +> +``` + + + + + +**Code:** + +```php +workspaces->get(); + +echo json_encode($workspace, JSON_PRETTY_PRINT); +``` + +**Output:** + +```json +{ + "workspace_id": "00000000-0000-0000-0000-000000000000", + "name": "Sandbox", + "company_name": "Acme", + "connect_partner_name": "Acme", + "is_sandbox": true +} +``` + + + + + +**Code:** + +```csharp +using Seam.Client; + +var seam = new SeamClient(apiToken: SEAM_API_KEY); + +var workspace = seam.Workspaces.Get(); + +Console.WriteLine(workspace); +``` + +**Output:** + +```json +{ + "workspace_id": "00000000-0000-0000-0000-000000000000", + "name": "Sandbox", + "company_name": "Acme", + "connect_partner_name": "Acme", + "is_sandbox": true +} +``` + + + diff --git a/mintlify-codegen/source/api/index.mdx b/mintlify-codegen/source/api/index.mdx new file mode 100644 index 000000000..aa7570fd1 --- /dev/null +++ b/mintlify-codegen/source/api/index.mdx @@ -0,0 +1,143 @@ +--- +title: "Introduction" +description: "The Seam API lets you control smart locks, thermostats, access control systems, and more through a unified REST interface." +--- + +The Seam API is organized around REST. It accepts JSON request bodies, returns JSON responses, and uses standard HTTP response codes and verbs. + +You can use the Seam API in a [sandbox workspace](/core-concepts/workspaces) without affecting live devices. The API key you use determines whether the request runs in sandbox or production mode. + +**Just getting started?** Check out the [quickstart guide](/quickstart). + +``` +Base URL: https://connect.getseam.com +``` + + + + JavaScript, Python, Ruby, PHP, C#, or Go. + + + API keys, client sessions, or personal access tokens. + + + Connect Seam to AI assistants via MCP. + + + +--- + +## Granting Access + +Use [Access Grants](/api/access_grants/object) to give people access to physical spaces. Tell Seam _who_ should have access, _where_, and _when_ — Seam automatically creates the right credential and programs it to the device. + + + The primary API for granting access — create, update, revoke, and manage the full lifecycle. + + +Access Grants support all access methods through a single API: + + + + Generate keypad codes for smart locks. Ongoing or time-bound. + + + Encode plastic cards for access control systems. + + + Issue mobile credentials via app or Instant Key URL. + + + Unlock doors remotely via API call. + + + +--- + +## Devices and Systems + +Connect and control IoT devices and access control systems. + + + + List, get, and update connected devices. + + + Lock and unlock smart locks. + + + Set temperature, HVAC mode, and climate presets. + + + Monitor noise levels and configure thresholds. + + + Manage access control system users, credentials, entrances, and encoders. + + + +--- + +## Setup + +Connect device accounts and configure your workspace. + + + + Authorization flows to link user device accounts. + + + Manage linked manufacturer accounts. + + + Map your users to Seam for cross-system identity. + + + Sandbox and production workspaces. + + + Scoped sessions for frontend apps. + + + Customer portals and data. + + + Organize devices and entrances into named groups. + + + +--- + +## Monitoring + +Track events, subscribe to webhooks, and poll for action completion. + + + + Query device and system events. + + + Real-time event notifications. + + + Poll async operations until completion. + + + +--- + +## Low-Level APIs + +These APIs let you program credentials directly on devices. For most access-granting workflows, use [Access Grants](/api/access_grants/object) instead — they handle credential creation, device programming, and lifecycle management automatically. + + + + Directly program PIN codes on smart lock keypads. + + + Temporary mobile keys without an app install. + + + Manage phones for mobile access credentials. + + diff --git a/mintlify-codegen/source/api/installation.mdx b/mintlify-codegen/source/api/installation.mdx new file mode 100644 index 000000000..0336294ca --- /dev/null +++ b/mintlify-codegen/source/api/installation.mdx @@ -0,0 +1,60 @@ +--- +title: 'SDK Installation' +description: 'Install a Seam SDK in your preferred language—JavaScript, Python, Ruby, PHP, or C#—to start building integrations with the Seam API in minutes.' +--- + + + **Building this integration with Claude?** Install the [Seam Docs MCP + server](/api/mcp-installation) first. It connects Claude directly to Seam's API + reference, integration guides, and 400+ device models so Claude can guide you + through implementation step by step — no copy-pasting from the docs. + + +Install one of the Seam SDKs in the programming language of your choice. Seam supports many programming languages, such as the following: + +- JavaScript / TypeScript ([npm](https://www.npmjs.com/package/seam), [GitHub](https://github.com/seamapi/javascript)) +- Python ([pip](https://pypi.org/project/seam/), [GitHub](https://github.com/seamapi/python)) +- Ruby Gem ([rubygem](https://rubygems.org/gems/seam), [GitHub](https://github.com/seamapi/ruby)) +- PHP ([packagist](https://packagist.org/packages/seamapi/seam), [GitHub](https://github.com/seamapi/php)) +- C# ([nuget](https://www.nuget.org/packages/Seam), [GitHub](https://github.com/seamapi/csharp)) + + + + +```bash +npm i seam +``` + + + + + +```bash +pip install seam +# For some development environments, use pip3 in this command instead of pip. +``` + + + + + +```bash +bundle add seam +``` + + + + + +```bash +composer require seamapi/seam +``` + + + + + Install using [nuget](https://www.nuget.org/packages/Seam). + + + + diff --git a/mintlify-codegen/source/api/mcp-installation.mdx b/mintlify-codegen/source/api/mcp-installation.mdx new file mode 100644 index 000000000..3f6605c1e --- /dev/null +++ b/mintlify-codegen/source/api/mcp-installation.mdx @@ -0,0 +1,98 @@ +--- +title: 'MCP Installation' +description: "Connect the Seam Docs MCP server to Claude to search Seam's API reference and device database from inside your conversations." +--- + +![](/images/mcp-cover.png) + +The **Seam Docs MCP Server** is a public, read-only [Model Context Protocol](https://modelcontextprotocol.io/) server that gives Claude and other MCP-compatible clients access to Seam's developer documentation and device database. Connect it once, then ask Claude questions about the Seam API, capabilities, webhooks, SDK usage, and the 400+ supported device models — across smart locks, thermostats, access control systems, and noise sensors. Claude grounds its answers in the most up-to-date Seam docs. + +The server is intended for developers integrating with Seam who want Claude to answer Seam questions accurately, whether they're looking up an API endpoint, checking which lock models support mobile keys, or finding the right integration guide. It is read-only: it cannot control devices, manage access codes, create resources, or perform any other write operations against the Seam API. + +## Server Details + +| Property | Value | +| -------------- | --------------------------- | +| Server URL | `https://mcp.seam.co/mcp` | +| Transport | Streamable HTTP | +| Authentication | None — the server is public | + + + No API key, account, or sign-up is required to use the Seam Docs MCP server. + It only reads and returns Seam's public documentation. + + +## What You Can Ask + +Once connected, you can ask Claude questions like the following, and Claude will fetch the answer from the Seam docs and device database: + +- "How do I unlock a Schlage Encode lock with the Seam API?" +- "Show me the webhook payload for `lock.locked`." +- "What's the difference between an access code and an access grant?" +- "Which smart lock models support mobile keys and remote unlock?" +- "Compare August and Yale lock features for a hospitality deployment." +- "Find the right integration guide for property management workflows." +- "Does Seam support Salto KS access control systems?" +- "How do I create a Connect Webview from the Python SDK?" + +## Connect From Claude.ai + +1. Sign in to [claude.ai](https://claude.ai). +2. Open **Settings → Connectors**. +3. Click **Add custom connector**. +4. Set the **URL** to `https://mcp.seam.co/mcp`. +5. Set the **Name** to `Seam Docs`. +6. Save. No authentication is required. + +The connector is now available to Claude in any new conversation. + +## Connect From Claude Desktop + +1. Open Claude Desktop and go to **Settings → Connectors**. +2. Click **Add custom connector**. +3. Set the **URL** to `https://mcp.seam.co/mcp`. +4. Set the **Name** to `Seam Docs`. +5. Save and restart Claude Desktop if prompted. + +## Connect From Claude Code + +Run the following command from your terminal: + +```bash +claude mcp add --transport http seam-docs https://mcp.seam.co/mcp +``` + +Or add the following entry to your Claude Code MCP configuration file: + +```json +{ + "mcpServers": { + "seam-docs": { + "type": "http", + "url": "https://mcp.seam.co/mcp" + } + } +} +``` + +## Available Tools + +The Seam Docs MCP server exposes three read-only tools: + +| Tool | Display Name | Description | +| ------------------- | ----------------- | -------------------------------------------------------------------------------------------------------- | +| `search_docs` | Search Seam Docs | Semantic search across all Seam documentation and the 400+ supported device models, ranked by relevance. | +| `get_doc` | Get Doc Page | Fetch the full content of any Seam documentation or device page. | +| `list_doc_sections` | List Doc Sections | Browse the Seam documentation tree to discover available content. | + +Claude selects the appropriate tool automatically based on your prompt. + +## Troubleshooting + +- **403 Forbidden on connection** — The server only accepts requests from approved origins (`mcp.seam.co`, `claude.ai`, `app.claude.ai`, and `localhost`). Requests from other origins are rejected. +- **Empty search results** — If `search_docs` returns nothing, broaden your query. Use Seam API resource names (for example, `access_codes`) rather than SDK-specific method names, and use generic capability terms (for example, "mobile keys") when searching for devices. +- **Connection refused** — Confirm that your network allows outbound HTTPS to `mcp.seam.co`. Some corporate firewalls block custom MCP endpoints. + +## Support + +For questions or issues with the Seam Docs MCP server, contact [support@seam.co](mailto:support@seam.co). diff --git a/mintlify-codegen/source/api/pagination.mdx b/mintlify-codegen/source/api/pagination.mdx new file mode 100644 index 000000000..258acf815 --- /dev/null +++ b/mintlify-codegen/source/api/pagination.mdx @@ -0,0 +1,1133 @@ +--- +title: 'Pagination' +description: 'Learn how to paginate Seam API list endpoints using the limit and page_cursor parameters and the pagination response object for faster, efficient apps.' +--- + +For endpoints that can return long lists of resources, using pagination makes your app faster and more efficient. The Seam API and our JavaScript, Python, PHP, and Ruby SDKs support pagination for `list` endpoints. + + + Currently, we support pagination for access codes, access system users, + Connect Webviews, connected accounts, and devices. + + +To fetch and process resources across multiple pages in the Seam API, use the `limit` and `page_cursor` parameters, along with the `pagination` response object. The `pagination` object provides the following information: + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyTypeDescription
+ next_page_cursor + String + Opaque value that you use to select the next page of results through the{' '} + page_cursor parameter. +
+ has_next_page + Boolean + Indicates whether there is another page of results after this one. +
+ next_page_url + String (URI)URL to get the next page of results.
+ +For pagination in the Seam SDKs, use the corresponding paginator class, for example, `SeamPaginator` in the Seam JavaScript and Python SDKs and `Paginator` in the Seam PHP and Ruby SDKs. + +The following examples show you how to use pagination in a variety of scenarios: + +## Manually Fetch Pages + +You can specify the number of records per page and the desired page of results. The first `list` request returns the first set of records, as well as the `pagination` object. If `pagination.has_next_page` is `true`, you can request an additional page of records. For all `list` requests after the first, use the `pagination.next_page_cursor` as the value for the `page_cursor` parameter. + +The following example gets the first page of 20 devices and then the second page of 20 devices: + + + + +**Code:** + +```javascript +const pages = seam.createPaginator( + seam.devices.list({ + limit: 20, + }), +) + +const [devices, { hasNextPage, nextPageCursor }] = await pages.firstPage() + +if (hasNextPage) { + const [moreDevices] = await pages.nextPage(nextPageCursor) +} +``` + +**Output:** + +```json +[ + { + device_id: '11111111-1111-1111-1111-444444444444', + ... + }, + ... +] +``` + + + + + +**Request:** + +```bash +# Use GET or POST. +curl -X 'GET' \ + 'https://connect.getseam.com/devices/list' \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${SEAM_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d '{ + "limit": 20 + }' + +curl -X 'GET' \ + 'https://connect.getseam.com/devices/list' \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${SEAM_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{ + \"limit\": 20 + \"page_cursor\": \"${next_page_cursor}\" + }" +``` + +**Response:** + +```json +{ + "devices": [ + { + "device_id": "11111111-1111-1111-1111-444444444444", + ... + }, + ... + ], + "pagination": { + "next_page_cursor": "[string]", + "has_next_page": true, + "next_page_url": "[URI]" + }, + "ok": true +} +``` + + + + + +**Code:** + +```python +paginator = seam.create_paginator(seam.devices.list, {"limit": 20}) + +devices, pagination = paginator.first_page() + +if pagination.has_next_page: + more_devices, _ = paginator.next_page(pagination.next_page_cursor) +``` + +**Output:** + +``` +[ + Device( + device_id='11111111-1111-1111-1111-444444444444', + ... + ), + ... +] +``` + + + + + +**Code:** + +```ruby +paginator = seam.create_paginator(seam.devices.method(:list), {limit: 20}) + +devices, pagination = paginator.first_page + +if pagination.has_next_page? + more_devices, _ = paginator.next_page(pagination.next_page_cursor) +end +``` + +**Output:** + +``` +[ + , + ... +] +``` + + + + + +**Code:** + +```php +$pages = $seam->createPaginator( + fn($params) => $seam->devices->list(...$params), + ["limit" => 2] +); + +[$devices, $pagination] = $pages->firstPage(); + +if ($pagination->has_next_page) { + [$moreDevices] = $pages->nextPage($pagination->next_page_cursor); +} +``` + +**Output:** + +```json +[ + { + "device_id": "11111111-1111-1111-1111-444444444444", + ... + }, + ... +] +``` + + + + + +**Code:** + +```csharp +// Coming soon! +``` + +**Output:** + +```json +// Coming soon! +``` + + + + +--- + +## Resume Pagination + +You can get the first page on initial load, store the state, and then get the next page at a later time using the stored state. + +The following example gets the first page of 20 records from the list of devices and, later, gets the next page of 20 devices: + + + + +**Code:** + +```javascript +// Get the first page of devices. +const params = { limit: 20 } + +const pages = seam.createPaginator(seam.devices.list(params)) + +const [devices, pagination] = await pages.firstPage() + +// Store the state, for example, in memory, a file, or a database. +localStorage.setItem('/seam/devices/list', JSON.stringify([params, pagination])) + +// Later, get the next page of devices. +const [params = {}, { hasNextPage = false, nextPageCursor = null } = {}] = + JSON.parse(localStorage.getItem('/seam/devices/list') ?? '[]') + +if (hasNextPage) { + const pages = seam.createPaginator(seam.devices.list(params)) + const [moreDevices] = await pages.nextPage(nextPageCursor) +} +``` + +**Output:** + +```json +[ + { + device_id: '11111111-1111-1111-1111-444444444444', + ... + }, + ... +] + +[ + { + device_id: '11111111-1111-1111-2222-444444444444', + ... + }, + ... +] +``` + + + + + +**Request:** + +```bash +# Get the first page. +# Use GET or POST. +curl -X 'GET' \ + 'https://connect.getseam.com/devices/list' \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${SEAM_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d '{ + "limit": 20 + }' + +# Store the state, for example, in memory, a file, or a database. + +# At a later time, retrieve the stored state. +# Then, get the next page using the stored state. +curl -X 'GET' \ + 'https://connect.getseam.com/devices/list' \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${SEAM_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{ + \"limit\": 20 + \"page_cursor\": \"${next_page_cursor}\" + }" +``` + +**Response:** + +```json +{ + "devices": [ + { + "device_id": "11111111-1111-1111-1111-444444444444", + ... + }, + ... + ], + "pagination": { + "next_page_cursor": "[string]", + "has_next_page": true, + "next_page_url": "[URI]" + }, + "ok": true +} + +{ + "devices": [ + { + "device_id": "11111111-1111-1111-2222-444444444444", + ... + }, + ... + ], + "pagination": { + "next_page_cursor": "[string]", + "has_next_page": true, + "next_page_url": "[URI]" + }, + "ok": true +} +``` + + + + + +**Code:** + +```python +# Get the first page. +params = {"limit": 20} +paginator = seam.create_paginator(seam.devices.list, params) + +devices, pagination = paginator.first_page() + +# Store the state, for example, in memory, a file, or a database. +pagination_state = { + "params": params, + "next_page_cursor": pagination.next_page_cursor, + "has_next_page": pagination.has_next_page, +} +with open("/tmp/seam_devices_list.json", "w") as f: + json.dump(pagination_state, f) + +# Get the next page at a later time using the stored state. +with open("/tmp/seam_devices_list.json", "r") as f: + pagination_state = json.load(f) + +if pagination_state.get("has_next_page"): + paginator = seam.create_paginator( + seam.devices.list, pagination_state["params"] + ) + more_devices, _ = paginator.next_page( + pagination_state["next_page_cursor"] + ) +``` + +**Output:** + +``` +[ + Device( + device_id='11111111-1111-1111-1111-444444444444', + ... + ), + ... +] + +[ + Device( + device_id='11111111-1111-1111-2222-444444444444', + ... + ), + ... +] +``` + + + + + +**Code:** + +```ruby +# Get the first page. +params = {limit: 20} +paginator = seam.create_paginator(seam.devices.method(:list), params) + +devices, pagination = paginator.first_page + +# Store the state, for example, in memory, a file, or a database. +pagination_state = { + "params" => params, + "next_page_cursor" => pagination.next_page_cursor, + "has_next_page" => pagination.has_next_page? +} +File.write("/tmp/seam_devices_list.json", JSON.dump(pagination_state)) + +# Get the next page at a later time using the stored state. +pagination_state_json = File.read("/tmp/seam_devices_list.json") +pagination_state = JSON.parse(pagination_state_json) + +if pagination_state["has_next_page"] + paginator = seam.create_paginator( + seam.devices.method(:list), pagination_state["params"] + ) + more_devices, _ = paginator.next_page( + pagination_state["next_page_cursor"] + ) +``` + +**Output:** + +``` +[ + , + ... +] + +[ + , + ... +] +``` + + + + + +**Code:** + +```php +// Get the first page. +$params = ["limit" => 20]; + +$pages = $seam->createPaginator( + fn($p) => $seam->devices->list(...$p), + $params +); + +[$devices, $pagination] = $pages->firstPage(); + +// Store the state, for example, in memory, a file, or a database. +file_put_contents( + "/tmp/seam_devices_list.json", + json_encode([$params, $pagination]) +); + +// Get the next page at a later time using the stored state. +$stored_data = json_decode( + file_get_contents("/tmp/seam_devices_list.json") ?: "[]", + false +); + +$params = $stored_data[0] ?? []; +$pagination = + $stored_data[1] ?? + (object) ["has_next_page" => false, "next_page_cursor" => null]; + +if ($pagination->has_next_page) { + $pages = $seam->createPaginator( + fn($p) => $seam->devices->list(...$p), + $params + ); + [$moreDevices] = $pages->nextPage($pagination->next_page_cursor); +} +``` + +**Output:** + +```json +[ + { + "device_id": "11111111-1111-1111-1111-444444444444", + ... + }, + ... +] + +[ + { + "device_id": "11111111-1111-1111-2222-444444444444", + ... + }, + ... +] +``` + + + + + +**Code:** + +```csharp +// Coming soon! +``` + +**Output:** + +```json +// Coming soon! +``` + + + + +--- + +## Iterate Over All Pages + +You can iterate over all pages of records. + +The following example uses a loop to get all pages of records for a list of 65 devices, at 20 records per page: + + + + +**Code:** + +```javascript +const pages = seam.createPaginator( + seam.devices.list({ + limit: 20, + }), +) + +for await (const devices of pages) { + console.log(`There are ${devices.length} devices on this page.`) +} +``` + +**Output:** + +``` +There are 20 devices on this page. +There are 20 devices on this page. +There are 20 devices on this page. +There are 5 devices on this page. +``` + + + + + +**Request:** + +```bash +# Get the first page. +# Use GET or POST. +response=$(curl -X 'GET' \ + 'https://connect.getseam.com/devices/list' \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${SEAM_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d '{ + "limit": 20 + }') + +echo "$response" | jq -r '.devices | length' | xargs -I {} echo "There are {} devices on this page." + +# Extract pagination info. +next_cursor=$(echo "$response" | jq -r '.pagination.next_page_cursor') +has_next=$(echo "$response" | jq -r '.pagination.has_next_page') + +# Process subsequent pages. +while [ "$has_next" = "true" ] && [ ! -z "$next_cursor" ]; do + response=$(curl -X 'GET' \ + 'https://connect.getseam.com/devices/list' \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${SEAM_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{ + \"limit\": 20, + \"page_cursor\": \"${next_cursor}\" + }") + + echo "$response" | jq -r '.devices | length' | xargs -I {} echo "There are {} devices on this page." + + # Update pagination info. + next_cursor=$(echo "$response" | jq -r '.pagination.next_page_cursor') + has_next=$(echo "$response" | jq -r '.pagination.has_next_page') +done +``` + +**Response:** + +``` +There are 20 devices on this page. +There are 20 devices on this page. +There are 20 devices on this page. +There are 5 devices on this page. +``` + + + + + +**Code:** + +```python +pages = seam.create_paginator( + seam.devices.list( + limit=20 + ) +) + +for devices in pages: + pprint(f"There are {len(devices)} devices on this page.") +``` + +**Output:** + +``` +There are 20 devices on this page. +There are 20 devices on this page. +There are 20 devices on this page. +There are 5 devices on this page. +``` + + + + + +**Code:** + +```ruby +params = {limit: 20} +pages = seam.create_paginator(seam.devices.method(:list), params) + +pages.each do |devices| + puts "There are #{devices.length} devices on this page." +end +``` + +**Output:** + +``` +There are 20 devices on this page. +There are 20 devices on this page. +There are 20 devices on this page. +There are 5 devices on this page. +``` + + + + + +**Code:** + +```php +$params = ["limit" => 20]; + +$paginator = $seam->createPaginator( + fn($p) => $seam->devices->list(...$p), + $params +); + +foreach ($paginator as $page) { + $devices = $page->devices; + echo "There are " . count($devices) . " devices on this page." . PHP_EOL; +} +``` + +**Output:** + +``` +There are 20 devices on this page. +There are 20 devices on this page. +There are 20 devices on this page. +There are 5 devices on this page. +``` + + + + + +**Code:** + +```csharp +// Coming soon! +``` + +**Output:** + +```json +// Coming soon! +``` + + + + +--- + +## Iterate Over All Resources + +You can iterate over all resources within all pages. + +The following example uses a loop to get all records for a list of devices, at 20 records per page, and then prints out the device ID for each record: + + + + +**Code:** + +```javascript +const pages = seam.createPaginator( + seam.devices.list({ + limit: 20, + }), +) + +for await (const device of pages.flatten()) { + console.log(device.device_id) +} +``` + +**Output:** + +``` +'11111111-1111-1111-1111-444444444444' +'11111111-1111-1111-2222-444444444444' +... +``` + + + + + +**Request:** + +```bash +# Get the first page. +# Use GET or POST. +response=$(curl -X 'GET' \ + 'https://connect.getseam.com/devices/list' \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${SEAM_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d '{ + "limit": 20 + }') + +# Process all pages. +while true; do + # Extract and print the device IDs from the current page. + echo "$response" | jq -r '.devices[].device_id' + + # Check whether there are more pages. + has_next=$(echo "$response" | jq -r '.pagination.has_next_page') + if [ "$has_next" != "true" ]; then + break + fi + + # Get the next page cursor. + next_cursor=$(echo "$response" | jq -r '.pagination.next_page_cursor') + if [ -z "$next_cursor" ] || [ "$next_cursor" = "null" ]; then + break + fi + + # Fetch the next page. + response=$(curl -X 'GET' \ + 'https://connect.getseam.com/devices/list' \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${SEAM_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{ + \"limit\": 20, + \"page_cursor\": \"${next_cursor}\" + }") +done +``` + +**Response:** + +``` +"11111111-1111-1111-1111-444444444444" +"11111111-1111-1111-2222-444444444444" +... +``` + + + + + +**Code:** + +```python +paginator = seam.create_paginator(seam.devices.list, {"limit": 20}) + +for device in paginator.flatten(): + print(device.device_id) +``` + +**Output:** + +``` +'11111111-1111-1111-1111-444444444444' +'11111111-1111-1111-2222-444444444444' +... +``` + + + + + +**Code:** + +```ruby +paginator = seam.create_paginator(seam.devices.method(:list), {limit: 20}) + +paginator.flatten.each do |device| + puts device.device_id +end +``` + +**Output:** + +``` +"11111111-1111-1111-1111-444444444444" +"11111111-1111-1111-2222-444444444444" +... +``` + + + + + +**Code:** + +```php +$pages = $seam->createPaginator( + fn($p) => $seam->devices->list(...$p), + ["limit" => 20] +); + +foreach ($pages->flatten() as $device) { + print $device->device_id . "\n"; +} +``` + +**Output:** + +```json +"11111111-1111-1111-1111-444444444444" +"11111111-1111-1111-2222-444444444444" +... +``` + + + + + +**Code:** + +```csharp +// Coming soon! +``` + +**Output:** + +```json +// Coming soon! +``` + + + + +--- + +## Return All Resources Across All Pages as an Array + +You can iterate over all resources within all pages and return a single array or list. + +The following example returns an array containing all devices: + + + + +**Code:** + +```javascript +const pages = seam.createPaginator( + seam.devices.list({ + limit: 20, + }), +) + +const devices = await pages.flattenToArray() +``` + +**Output:** + +```json +[ + { + device_id: '11111111-1111-1111-1111-444444444444', + ... + }, + { + device_id: '11111111-1111-1111-2222-444444444444', + ... + }, + ... +] +``` + + + + + +**Request:** + +```bash +# Get the first page. +# Use GET or POST. +response=$(curl -X 'GET' \ + 'https://connect.getseam.com/devices/list' \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${SEAM_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d '{ + "limit": 20 + }') + +# Process all pages. +while true; do + # Extract all devices from the current page and add them + # the all_devices array. + devices=$(echo "$response" | jq -c '.devices[]') + while IFS= read -r device; do + [ -n "$device" ] && all_devices+=("$device") + done <<< "$devices" + + # Check whether there are more pages. + has_next=$(echo "$response" | jq -r '.pagination.has_next_page') + if [ "$has_next" != "true" ]; then + break + fi + + # Get the next page cursor. + next_cursor=$(echo "$response" | jq -r '.pagination.next_page_cursor') + if [ -z "$next_cursor" ] || [ "$next_cursor" = "null" ]; then + break + fi + + # Fetch the next page. + response=$(curl -X 'GET' \ + 'https://connect.getseam.com/devices/list' \ + -H 'accept: application/json' \ + -H "Authorization: Bearer ${SEAM_API_KEY}" \ + -H 'Content-Type: application/json' \ + -d "{ + \"limit\": 20, + \"page_cursor\": \"${next_cursor}\" + }") +done +``` + +**Response:** + +```json +[ + { + device_id: '11111111-1111-1111-1111-444444444444', + ... + }, + { + device_id: '11111111-1111-1111-2222-444444444444', + ... + }, + ... +] +``` + + + + + +**Code:** + +```python +paginator = seam.create_paginator(seam.devices.list, {"limit": 20}) + +all_devices = paginator.flatten_to_list() +``` + +**Output:** + +``` +[ + Device( + device_id='11111111-1111-1111-1111-444444444444', + ... + ), + Device( + device_id='11111111-1111-1111-2222-444444444444', + ... + ), + ... +] +``` + + + + + +**Code:** + +```ruby +paginator = seam.create_paginator(seam.devices.method(:list), {limit: 20}) + +all_devices = paginator.flatten_to_list +``` + +**Output:** + +``` +[ + , + , + ... +] +``` + + + + + +**Code:** + +```php +$pages = $seam->createPaginator( + fn($p) => $seam->devices->list(...$p), + ["limit" => 20] +); + +$deviecs = $pages->flattenToArray(); +``` + +**Output:** + +```json +[ + { + "device_id": "11111111-1111-1111-1111-444444444444", + ... + }, + { + "device_id": "11111111-1111-1111-2222-444444444444", + ... + }, + ... +] +``` + + + + + +**Code:** + +```csharp +// Coming soon! +``` + +**Output:** + +```json +// Coming soon! +``` + + + diff --git a/mintlify-codegen/source/api/rate-limits-and-guardrails.mdx b/mintlify-codegen/source/api/rate-limits-and-guardrails.mdx new file mode 100644 index 000000000..485767098 --- /dev/null +++ b/mintlify-codegen/source/api/rate-limits-and-guardrails.mdx @@ -0,0 +1,63 @@ +--- +title: 'Rate Limits and Guardrails' +description: 'Learn the Seam API rate limits and guardrails, how 429 Too Many Requests responses work, and how to retry requests for reliable device load.' +--- + +In order to provide reliable service for all of our customers and API load predictability for our device partners, we have a number of rate limits and guardrails in place. + +## Rate Limits + +For requests with rate limits, exceeding these limits results in the corresponding API calls failing with a `429 Too Many Requests` response. You must then retry your request after the appropriate amount of time. + +We enforce the following rate limits on a per-device basis: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OperationLimit
+ + Create an access code + + 10 access code creations per minute
+ + Update an access code + + 10 access code updates per minute
+ + Delete an access code + + 20 access code deletions per minute
+ + Lock a lock + + 15 lock requests per minute
+ + Unlock a lock + + 15 unlock requests per minute
diff --git a/mintlify-docs/api/access_codes/simulate/object.mdx b/mintlify-docs/api/access_codes/simulate/object.mdx index 76cca742e..8932962ae 100644 --- a/mintlify-docs/api/access_codes/simulate/object.mdx +++ b/mintlify-docs/api/access_codes/simulate/object.mdx @@ -7,4 +7,4 @@ description: 'Use the access code simulation endpoints to create unmanaged acces [**`/access_codes/simulate/create_unmanaged_access_code`**](./create_unmanaged_access_code) -Simulates the creation of an [unmanaged access code](/low-level-apis/smart-locks/access-codes/migrating-existing-access-codes) in a [sandbox workspace](/core-concepts/workspaces#sandbox-workspaces). +Simulates the creation of an [unmanaged access code](https://docs.seam.co/low-level-apis/smart-locks/access-codes/migrating-existing-access-codes) in a [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). diff --git a/mintlify-docs/api/access_grants/unmanaged/object.mdx b/mintlify-docs/api/access_grants/unmanaged/object.mdx index 910ad2e66..b36d1d510 100644 --- a/mintlify-docs/api/access_grants/unmanaged/object.mdx +++ b/mintlify-docs/api/access_grants/unmanaged/object.mdx @@ -16,3 +16,7 @@ Gets unmanaged Access Grants (where is_managed = false). [**`/access_grants/unmanaged/update`**](./update) Updates an unmanaged Access Grant to make it managed. + +This endpoint can only be used to convert unmanaged access grants to managed ones by setting `is_managed` to `true`. It cannot be used to convert managed access grants back to unmanaged. + +When converting an unmanaged access grant to managed, all associated access methods will also be converted to managed. diff --git a/mintlify-docs/api/acs/encoders/simulate/object.mdx b/mintlify-docs/api/acs/encoders/simulate/object.mdx index 24f7bb24e..cce6545b3 100644 --- a/mintlify-docs/api/acs/encoders/simulate/object.mdx +++ b/mintlify-docs/api/acs/encoders/simulate/object.mdx @@ -7,16 +7,16 @@ description: 'Use the encoder simulation endpoints to make the next credential e [**`/acs/encoders/simulate/next_credential_encode_will_fail`**](./next_credential_encode_will_fail) -Simulates that the next attempt to encode a [credential](/low-level-apis/access-systems/managing-credentials) using the specified [encoder](/low-level-apis/access-systems/working-with-card-encoders-and-scanners) will fail. You can only perform this action within a [sandbox workspace](/core-concepts/workspaces#sandbox-workspaces). +Simulates that the next attempt to encode a [credential](https://docs.seam.co/low-level-apis/access-systems/managing-credentials) using the specified [encoder](https://docs.seam.co/low-level-apis/access-systems/working-with-card-encoders-and-scanners) will fail. You can only perform this action within a [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). [**`/acs/encoders/simulate/next_credential_encode_will_succeed`**](./next_credential_encode_will_succeed) -Simulates that the next attempt to encode a [credential](/low-level-apis/access-systems/managing-credentials) using the specified [encoder](/low-level-apis/access-systems/working-with-card-encoders-and-scanners) will succeed. You can only perform this action within a [sandbox workspace](/core-concepts/workspaces#sandbox-workspaces). +Simulates that the next attempt to encode a [credential](https://docs.seam.co/low-level-apis/access-systems/managing-credentials) using the specified [encoder](https://docs.seam.co/low-level-apis/access-systems/working-with-card-encoders-and-scanners) will succeed. You can only perform this action within a [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). [**`/acs/encoders/simulate/next_credential_scan_will_fail`**](./next_credential_scan_will_fail) -Simulates that the next attempt to scan a [credential](/low-level-apis/access-systems/managing-credentials) using the specified [encoder](/low-level-apis/access-systems/working-with-card-encoders-and-scanners) will fail. You can only perform this action within a [sandbox workspace](/core-concepts/workspaces#sandbox-workspaces). +Simulates that the next attempt to scan a [credential](https://docs.seam.co/low-level-apis/access-systems/managing-credentials) using the specified [encoder](https://docs.seam.co/low-level-apis/access-systems/working-with-card-encoders-and-scanners) will fail. You can only perform this action within a [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). [**`/acs/encoders/simulate/next_credential_scan_will_succeed`**](./next_credential_scan_will_succeed) -Simulates that the next attempt to scan a [credential](/low-level-apis/access-systems/managing-credentials) using the specified [encoder](/low-level-apis/access-systems/working-with-card-encoders-and-scanners) will succeed. You can only perform this action within a [sandbox workspace](/core-concepts/workspaces#sandbox-workspaces). +Simulates that the next attempt to scan a [credential](https://docs.seam.co/low-level-apis/access-systems/managing-credentials) using the specified [encoder](https://docs.seam.co/low-level-apis/access-systems/working-with-card-encoders-and-scanners) will succeed. You can only perform this action within a [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). diff --git a/mintlify-docs/api/acs/object.mdx b/mintlify-docs/api/acs/object.mdx index a4185d6da..44a38d180 100644 --- a/mintlify-docs/api/acs/object.mdx +++ b/mintlify-docs/api/acs/object.mdx @@ -26,25 +26,27 @@ The Access Control Systems (ACS) namespace contains the following resources: Group that defines the entrances to which a set of users has access and, in some cases, the access schedule for these entrances and users. -Some access control systems use [access group](/low-level-apis/access-systems/user-management/assigning-users-to-access-groups), which are sets of users, combined with sets of permissions. These permissions include both the set of areas or assets that the users can access and the schedule during which the users can access these areas or assets. Instead of assigning access rights individually to each access control system user, which can be time-consuming and error-prone, administrators can assign users to an access group, thereby ensuring that the users inherit all the permissions associated with the access group. Using access groups streamlines the process of managing large numbers of access control system users, especially in bigger organizations or complexes. +Some access control systems use [access group](https://docs.seam.co/low-level-apis/access-systems/user-management/assigning-users-to-access-groups), which are sets of users, combined with sets of permissions. These permissions include both the set of areas or assets that the users can access and the schedule during which the users can access these areas or assets. Instead of assigning access rights individually to each access control system user, which can be time-consuming and error-prone, administrators can assign users to an access group, thereby ensuring that the users inherit all the permissions associated with the access group. Using access groups streamlines the process of managing large numbers of access control system users, especially in bigger organizations or complexes. -To learn whether your access control system supports access groups, see the corresponding [system integration guide](/device-and-system-integration-guides#access-control-systems). +To learn whether your access control system supports access groups, see the corresponding [system integration guide](https://docs.seam.co/device-and-system-integration-guides#access-control-systems). --- ### [`acs_credential`](#acs_credential) -Means by which an [access control system user](/low-level-apis/access-systems/user-management) gains access at an [entrance](/low-level-apis/access-systems/retrieving-entrance-details). The `acs_credential` object represents a [credential](/low-level-apis/access-systems/managing-credentials) that provides an ACS user access within an [access control system](/low-level-apis/access-systems). +Means by which an [access control system user](https://docs.seam.co/low-level-apis/access-systems/user-management) gains access at an [entrance](https://docs.seam.co/low-level-apis/access-systems/retrieving-entrance-details). The `acs_credential` object represents a [credential](https://docs.seam.co/low-level-apis/access-systems/managing-credentials) that provides an ACS user access within an [access control system](https://docs.seam.co/low-level-apis/access-systems). An access control system generally uses digital means of access to authorize a user trying to get through a specific entrance. Examples of credentials include plastic key cards, mobile keys, biometric identifiers, and PIN codes. The electronic nature of these credentials, as well as the fact that access is centralized, enables both the rapid provisioning and rescinding of access and the ability to compile access audit logs. For each `acs_credential`, you define the access method. You can also specify additional properties, such as a PIN code, depending on the credential type. +For granting a person access to a space, [Access Grants](https://docs.seam.co/use-cases/granting-access) are the default and recommended approach. Use the lower-level ACS credential API directly only when you specifically need to manage individual credentials. + --- ### [`acs_encoder`](#acs_encoder) -Represents a hardware device that encodes [credential](/low-level-apis/access-systems/managing-credentials) data onto physical cards within an [access control system](/low-level-apis/access-systems). +Represents a hardware device that encodes [credential](https://docs.seam.co/low-level-apis/access-systems/managing-credentials) data onto physical cards within an [access control system](https://docs.seam.co/low-level-apis/access-systems). Some access control systems require credentials to be encoded onto plastic key cards using a card encoder. This process involves the following two key steps: @@ -55,15 +57,15 @@ Some access control systems require credentials to be encoded onto plastic key c Separately, the Seam API also supports card scanning, which enables you to scan and read the encoded data on a card. You can use this action to confirm consistency with access control system records or diagnose discrepancies if needed. -See [Working with Card Encoders and Scanners](/low-level-apis/access-systems/working-with-card-encoders-and-scanners). +See [Working with Card Encoders and Scanners](https://docs.seam.co/low-level-apis/access-systems/working-with-card-encoders-and-scanners). -To verify if your access control system requires a card encoder, see the corresponding [system integration guide](/device-and-system-integration-guides#access-control-systems). +To verify if your access control system requires a card encoder, see the corresponding [system integration guide](https://docs.seam.co/device-and-system-integration-guides#access-control-systems). --- ### [`acs_entrance`](#acs_entrance) -Represents an [entrance](/low-level-apis/access-systems/retrieving-entrance-details) within an [access control system](/low-level-apis/access-systems). +Represents an [entrance](https://docs.seam.co/low-level-apis/access-systems/retrieving-entrance-details) within an [access control system](https://docs.seam.co/low-level-apis/access-systems). In an access control system, an entrance is a secured door, gate, zone, or other method of entry. You can list details for all the `acs_entrance` resources in your workspace or get these details for a specific `acs_entrance`. You can also list all entrances associated with a specific credential, and you can list all credentials associated with a specific entrance. @@ -71,20 +73,20 @@ In an access control system, an entrance is a secured door, gate, zone, or other ### [`acs_system`](#acs_system) -Represents an [access control system](/low-level-apis/access-systems). +Represents an [access control system](https://docs.seam.co/low-level-apis/access-systems). -Within an `acs_system`, create [`acs_user`s](/api/acs/users/object) and [`acs_credential`s](/api/acs/credentials/object) to grant access to the `acs_user`s. +Within an `acs_system`, create [`acs_user`s](https://docs.seam.co/api/acs/users/object#acs_user) and [`acs_credential`s](https://docs.seam.co/api/acs/credentials/object#acs_credential) to grant access to the `acs_user`s. -For details about the resources associated with an access control system, see the [access control systems namespace](/api/acs/object). +For details about the resources associated with an access control system, see the [access control systems namespace](https://docs.seam.co/api/acs/object). --- ### [`acs_user`](#acs_user) -Represents a [user](/low-level-apis/access-systems/user-management) in an [access system](/low-level-apis/access-systems). +Represents a [user](https://docs.seam.co/low-level-apis/access-systems/user-management) in an [access system](https://docs.seam.co/low-level-apis/access-systems). An access system user typically refers to an individual who requires access, like an employee or resident. Each user can possess multiple credentials that serve as their keys or identifiers for access. The type of credential can vary widely. For example, in the Salto system, a user can have a PIN code, a mobile app account, and a fob. In other platforms, it is not uncommon for a user to have more than one of the same credential type, such as multiple key cards. Additionally, these credentials can have a schedule or validity period. -For details about how to configure users in your access system, see the corresponding [system integration guide](/device-and-system-integration-guides#access-control-systems). +For details about how to configure users in your access system, see the corresponding [system integration guide](https://docs.seam.co/device-and-system-integration-guides#access-control-systems). --- diff --git a/mintlify-docs/api/action_attempts/object.mdx b/mintlify-docs/api/action_attempts/object.mdx index d2bf35a6c..287082197 100644 --- a/mintlify-docs/api/action_attempts/object.mdx +++ b/mintlify-docs/api/action_attempts/object.mdx @@ -55,6 +55,8 @@ Type of the action attempt. - UNLOCK_DOOR - SCAN_CREDENTIAL - ENCODE_CREDENTIAL +- SCAN_TO_ASSIGN_CREDENTIAL +- ASSIGN_CREDENTIAL - RESET_SANDBOX_WORKSPACE - SET_FAN_MODE - SET_HVAC_MODE @@ -83,8 +85,8 @@ Result of the action attempt. Null for pending and errored action attempts. [**`/action_attempts/get`**](./get) -Returns a specified [action attempt](/core-concepts/action-attempts). +Returns a specified [action attempt](https://docs.seam.co/core-concepts/action-attempts). [**`/action_attempts/list`**](./list) -Returns a list of the [action attempts](/core-concepts/action-attempts) that you specify as an array of `action_attempt_id`s. +Returns a list of the [action attempts](https://docs.seam.co/core-concepts/action-attempts) that you specify as an array of `action_attempt_id`s. diff --git a/mintlify-docs/api/connected_accounts/simulate/object.mdx b/mintlify-docs/api/connected_accounts/simulate/object.mdx index 3533e7347..fbb80381c 100644 --- a/mintlify-docs/api/connected_accounts/simulate/object.mdx +++ b/mintlify-docs/api/connected_accounts/simulate/object.mdx @@ -7,4 +7,4 @@ description: 'Use the connected account simulation endpoint to disconnect an acc [**`/connected_accounts/simulate/disconnect`**](./disconnect) -Simulates a connected account becoming disconnected from Seam. Only applicable for [sandbox workspaces](/core-concepts/workspaces#sandbox-workspaces). +Simulates a connected account becoming disconnected from Seam. Only applicable for [sandbox workspaces](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). diff --git a/mintlify-docs/api/devices/simulate/object.mdx b/mintlify-docs/api/devices/simulate/object.mdx index 14b248969..8b1825daa 100644 --- a/mintlify-docs/api/devices/simulate/object.mdx +++ b/mintlify-docs/api/devices/simulate/object.mdx @@ -7,27 +7,25 @@ description: 'Use the device simulation endpoints to connect, disconnect, and re [**`/devices/simulate/connect`**](./connect) -Simulates connecting a device to Seam. Only applicable for [sandbox devices](/core-concepts/workspaces#sandbox-workspaces). See also [Testing Your App Against Device Disconnection and Removal](/core-concepts/devices/testing-your-app-against-device-disconnection-and-removal). +Simulates connecting a device to Seam. Only applicable for [sandbox devices](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). See also [Testing Your App Against Device Disconnection and Removal](https://docs.seam.co/core-concepts/devices/testing-your-app-against-device-disconnection-and-removal). [**`/devices/simulate/connect_to_hub`**](./connect_to_hub) Simulates bringing the Wi‑Fi hub (bridge) back online for a device. Only applicable for sandbox workspaces and currently implemented for August and TTLock locks. -This will clear the corresponding `hub_disconnected` or -`ttlock_lock_not_paired_to_gateway` error on the device. +This will clear the `hub_disconnected` error on the device. [**`/devices/simulate/disconnect`**](./disconnect) -Simulates disconnecting a device from Seam. Only applicable for [sandbox devices](/core-concepts/workspaces#sandbox-workspaces). See also [Testing Your App Against Device Disconnection and Removal](/core-concepts/devices/testing-your-app-against-device-disconnection-and-removal). +Simulates disconnecting a device from Seam. Only applicable for [sandbox devices](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). See also [Testing Your App Against Device Disconnection and Removal](https://docs.seam.co/core-concepts/devices/testing-your-app-against-device-disconnection-and-removal). [**`/devices/simulate/disconnect_from_hub`**](./disconnect_from_hub) Simulates taking the Wi‑Fi hub (bridge) offline for a device. Only applicable for sandbox workspaces and currently implemented for August, TTLock, and IglooHome devices. -This will set the corresponding `hub_disconnected` or -`ttlock_lock_not_paired_to_gateway` error on the device, or mark the +This will set the `hub_disconnected` error on the device, or mark the IglooHome bridge offline in sandbox. [**`/devices/simulate/paid_subscription`**](./paid_subscription) @@ -38,4 +36,4 @@ The actual device error is created/cleared by the poller after this state change [**`/devices/simulate/remove`**](./remove) -Simulates removing a device from Seam. Only applicable for [sandbox devices](/core-concepts/workspaces#sandbox-workspaces). See also [Testing Your App Against Device Disconnection and Removal](/core-concepts/devices/testing-your-app-against-device-disconnection-and-removal). +Simulates removing a device from Seam. Only applicable for [sandbox devices](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). See also [Testing Your App Against Device Disconnection and Removal](https://docs.seam.co/core-concepts/devices/testing-your-app-against-device-disconnection-and-removal). diff --git a/mintlify-docs/api/locks/object.mdx b/mintlify-docs/api/locks/object.mdx index 24cf10f38..c0e65b4bf 100644 --- a/mintlify-docs/api/locks/object.mdx +++ b/mintlify-docs/api/locks/object.mdx @@ -166,87 +166,127 @@ A lock device resource. **`can_configure_auto_lock`** _Boolean_ +Indicates whether the lock supports configuring automatic locking. + --- **`can_hvac_cool`** _Boolean_ +Indicates whether the thermostat supports cooling. + --- **`can_hvac_heat`** _Boolean_ +Indicates whether the thermostat supports heating. + --- **`can_hvac_heat_cool`** _Boolean_ +Indicates whether the thermostat supports simultaneous heating and cooling. + --- **`can_program_offline_access_codes`** _Boolean_ +Indicates whether the device supports programming offline access codes. + --- **`can_program_online_access_codes`** _Boolean_ +Indicates whether the device supports programming online access codes. + --- **`can_program_thermostat_programs_as_different_each_day`** _Boolean_ +Indicates whether the thermostat supports different climate programs for each day of the week. + --- **`can_program_thermostat_programs_as_same_each_day`** _Boolean_ +Indicates whether the thermostat supports a single climate program applied to every day. + --- **`can_program_thermostat_programs_as_weekday_weekend`** _Boolean_ +Indicates whether the thermostat supports weekday/weekend climate programs. + --- **`can_remotely_lock`** _Boolean_ +Indicates whether the device supports remote locking. + --- **`can_remotely_unlock`** _Boolean_ +Indicates whether the device supports remote unlocking. + --- **`can_run_thermostat_programs`** _Boolean_ +Indicates whether the thermostat supports running climate programs. + --- **`can_simulate_connection`** _Boolean_ +Indicates whether the device supports simulating connection in a sandbox. + --- **`can_simulate_disconnection`** _Boolean_ +Indicates whether the device supports simulating disconnection in a sandbox. + --- **`can_simulate_hub_connection`** _Boolean_ +Indicates whether the hub supports simulating connection in a sandbox. + --- **`can_simulate_hub_disconnection`** _Boolean_ +Indicates whether the hub supports simulating disconnection in a sandbox. + --- **`can_simulate_paid_subscription`** _Boolean_ +Indicates whether the device supports simulating a paid subscription in a sandbox. + --- **`can_simulate_removal`** _Boolean_ +Indicates whether the device supports simulating removal in a sandbox. + --- **`can_turn_off_hvac`** _Boolean_ +Indicates whether the thermostat can be turned off. + --- **`can_unlock_with_code`** _Boolean_ +Indicates whether the lock supports unlocking with an access code. + --- **`capabilities_supported`** _List_ _of Enums_ -Collection of capabilities that the device supports when connected to Seam. Values are `access_code`, which indicates that the device can manage and utilize digital PIN codes for secure access; `lock`, which indicates that the device controls a door locking mechanism, enabling the remote opening and closing of doors and other entry points; `noise_detection`, which indicates that the device supports monitoring and responding to ambient noise levels; `thermostat`, which indicates that the device can regulate and adjust indoor temperatures; `battery`, which indicates that the device can manage battery life and health; and `phone`, which indicates that the device is a mobile device, such as a smartphone. **Important:** Superseded by [capability flags](/capability-guides/device-and-system-capabilities#capability-flags). +Collection of capabilities that the device supports when connected to Seam. Values are `access_code`, which indicates that the device can manage and utilize digital PIN codes for secure access; `lock`, which indicates that the device controls a door locking mechanism, enabling the remote opening and closing of doors and other entry points; `noise_detection`, which indicates that the device supports monitoring and responding to ambient noise levels; `thermostat`, which indicates that the device can regulate and adjust indoor temperatures; `battery`, which indicates that the device can manage battery life and health; and `phone`, which indicates that the device is a mobile device, such as a smartphone. **Important:** Superseded by [capability flags](https://docs.seam.co/capability-guides/device-and-system-capabilities#capability-flags). --- @@ -264,7 +304,7 @@ Date and time at which the device object was created. **`custom_metadata`** _Record_ -Set of key:value pairs. Adding custom metadata to a resource, such as a [Connect Webview](/core-concepts/connect-webviews/attaching-custom-data-to-the-connect-webview), [connected account](/core-concepts/connected-accounts/adding-custom-metadata-to-a-connected-account), or [device](/core-concepts/devices/adding-custom-metadata-to-a-device), enables you to store custom information, like customer details or internal IDs from your application. +Set of key:value pairs. Adding custom metadata to a resource, such as a [Connect Webview](https://docs.seam.co/core-concepts/connect-webviews/attaching-custom-data-to-the-connect-webview), [connected account](https://docs.seam.co/core-concepts/connected-accounts/adding-custom-metadata-to-a-connected-account), or [device](https://docs.seam.co/core-concepts/devices/adding-custom-metadata-to-a-device), enables you to store custom information, like customer details or internal IDs from your application. --- @@ -306,7 +346,8 @@ Type of the device. - tedee_lock - akiles_lock - ultraloq_lock -- korelock_lock +- keyincode_lock +- omnitec_lock - keynest_key - noiseaware_activity_zone - minut_sensor @@ -359,7 +400,7 @@ Enum values: **`is_connected_account_error`** _Boolean_ -Indicates that the error is a [connected account](/api/connected_accounts/object) error. +Indicates that the error is a [connected account](https://docs.seam.co/api/connected_accounts/object) error. --- @@ -396,7 +437,44 @@ Enum values: **`is_connected_account_error`** _Boolean_ -Indicates that the error is a [connected account](/api/connected_accounts/object) error. +Indicates that the error is a [connected account](https://docs.seam.co/api/connected_accounts/object) error. + +--- + +**`is_device_error`** _Boolean_ + +Indicates that the error is not a device error. + +--- + +**`message`** _String_ + +Detailed description of the error. Provides insights into the issue and potentially how to rectify it. + +
+ + +Indicates that one or more dormakaba sites associated with the connected account could not be connected. Contact dormakaba support. + +**`created_at`** _Datetime_ + +Date and time at which Seam created the error. + +--- + +**`error_code`** _Enum_ + +Unique identifier of the type of error. Enables quick recognition and categorization of the issue. + +Enum values: + +- dormakaba_sites_disconnected + +--- + +**`is_connected_account_error`** _Boolean_ + +Indicates that the error is a [connected account](https://docs.seam.co/api/connected_accounts/object) error. --- @@ -537,7 +615,7 @@ Detailed description of the error. Provides insights into the issue and potentia -Indicates that the [backup access code pool](/low-level-apis/smart-locks/access-codes/backup-access-codes) is empty. +Indicates that the [backup access code pool](https://docs.seam.co/low-level-apis/smart-locks/access-codes/backup-access-codes) is empty. **`created_at`** _Datetime_ @@ -785,7 +863,7 @@ Detailed description of the error. Provides insights into the issue and potentia -Indicates that the Seam API cannot communicate with [Seam Bridge](/capability-guides/seam-bridge), for example, if the Seam Bridge executable has stopped or if the computer running the Seam Bridge executable is offline. See also [Troubleshooting Your Access Control System](/low-level-apis/access-systems/troubleshooting-your-access-control-system). +Indicates that the Seam API cannot communicate with [Seam Bridge](https://docs.seam.co/capability-guides/seam-bridge), for example, if the Seam Bridge executable has stopped or if the computer running the Seam Bridge executable is offline. See also [Troubleshooting Your Access Control System](https://docs.seam.co/low-level-apis/access-systems/troubleshooting-your-access-control-system#acs_system.errors.seam_bridge_disconnected). **`created_at`** _Datetime_ @@ -805,7 +883,7 @@ Enum values: **`is_bridge_error`** _Boolean_ -Indicates whether the error is related to [Seam Bridge](/capability-guides/seam-bridge). +Indicates whether the error is related to [Seam Bridge](https://docs.seam.co/capability-guides/seam-bridge). --- @@ -825,7 +903,7 @@ Detailed description of the error. Provides insights into the issue and potentia **`is_managed`** _Boolean_ -Indicates whether Seam manages the device. See also [Managed and Unmanaged Devices](/core-concepts/devices/managed-and-unmanaged-devices). +Indicates whether Seam manages the device. See also [Managed and Unmanaged Devices](https://docs.seam.co/core-concepts/devices/managed-and-unmanaged-devices). --- @@ -1198,6 +1276,31 @@ Enum values: - salto_ks_subscription_limit_almost_reached + + + +Indicates that a change in the reported device model has been detected for this Salto KS lock, which may occur after an IQ hub reset. Access code support may be affected. See https://help.getseam.com/articles/5098842588-salto-ks-lock-loses-access-code-support for troubleshooting steps. + +**`created_at`** _Datetime_ + +Date and time at which Seam created the warning. + +--- + +**`message`** _String_ + +Detailed description of the warning. Provides insights into the issue and potentially how to rectify it. + +--- + +**`warning_code`** _Enum_ + +Unique identifier of the type of warning. Enables quick recognition and categorization of the issue. + +Enum values: + +- salto_ks_lock_access_code_support_removed + @@ -1323,6 +1426,31 @@ Enum values: - hub_required_for_additional_capabilities + + + +Indicates a provider-specific issue that may affect device functionality. + +**`created_at`** _Datetime_ + +Date and time at which Seam created the warning. + +--- + +**`message`** _String_ + +Detailed description of the warning. Provides insights into the issue and potentially how to rectify it. + +--- + +**`warning_code`** _Enum_ + +Unique identifier of the type of warning. Enables quick recognition and categorization of the issue. + +Enum values: + +- provider_issue + @@ -1435,6 +1563,31 @@ Enum values: - max_access_codes_reached + + + +Indicates that the connected Kwikset account has member-level access to this lock's home. Admin or owner access is required to manage access codes and control the lock remotely. + +**`created_at`** _Datetime_ + +Date and time at which Seam created the warning. + +--- + +**`message`** _String_ + +Detailed description of the warning. Provides insights into the issue and potentially how to rectify it. + +--- + +**`warning_code`** _Enum_ + +Unique identifier of the type of warning. Enables quick recognition and categorization of the issue. + +Enum values: + +- insufficient_permissions + --- @@ -1483,9 +1636,9 @@ ASSA ABLOY Credential Service metadata for the phone.
Indicated whether the endpoint is active. - - Indicates whether the credential service has active endpoints associated with the phone. - + + + Indicates whether the credential service has active endpoints associated with the phone.
@@ -1715,17 +1868,13 @@ Metadata for a dormakaba Oracode device. Prefix for a time slot for a dormakaba Oracode device. - - Site ID for a dormakaba Oracode device. - - **Deprecated**. Previously marked as "@DEPRECATED."- - - site_name - - String - - Site name for a dormakaba Oracode device. - + + + Site ID for a dormakaba Oracode device. + **Deprecated**. Previously marked as "@DEPRECATED." + + + Site name for a dormakaba Oracode device. @@ -2210,6 +2359,31 @@ Indicates whether it is currently possible to use offline access codes for the d --- +**`omnitec_metadata`** _Object_ + +Metadata for an Omnitec device. + + + + + Whether the Omnitec lock has a connected gateway for remote operations. + + + Lock ID for an Omnitec device. + + + Bluetooth MAC address for an Omnitec device. + + + Lock name for an Omnitec device. + + + Static UTC offset of the Omnitec lock in milliseconds. Does not account for DST. + + + +--- + **`online`** _Boolean_ Indicates whether the device is online. @@ -2431,7 +2605,7 @@ Supported code lengths for access codes. **`supports_backup_access_code_pool`** _Boolean_ -Indicates whether the device supports a [backup access code pool](/low-level-apis/smart-locks/access-codes/backup-access-codes). +Indicates whether the device supports a [backup access code pool](https://docs.seam.co/low-level-apis/smart-locks/access-codes/backup-access-codes). --- @@ -2530,6 +2704,9 @@ Metadata for a TTLock device. Lock ID for a TTLock device. + + Lock-side timezone offset in milliseconds east of UTC, as configured in the TTLock app. Source of truth for the lock's wall-clock interpretation of access code start/end times — a misconfigured value here is the typical cause of customer "codes offset by N hours" reports. Diagnostic only; Seam does not convert times based on this value. + Wireless keypads for a TTLock device. @@ -2630,12 +2807,30 @@ Metadata for a Wyze device. ## Errors +**`ttlock_lock_not_paired_to_gateway`** + +Indicates that the lock is not paired with a gateway. + +--- + **`salto_ks_subscription_limit_exceeded`** Indicates that the Salto site user limit has been reached. --- +**`lockly_missing_wifi_bridge`** + +Indicates that the Lockly lock is not connected to a Wi-Fi bridge. + +--- + +**`dormakaba_sites_disconnected`** + +Indicates that one or more dormakaba sites associated with the connected account could not be connected. Contact dormakaba support. + +--- + **`august_lock_not_authorized`** Indicates that the user is not authorized to use the August lock. @@ -2650,13 +2845,7 @@ Indicates that the lock is not connected to a bridge. **`empty_backup_access_code_pool`** -Indicates that the [backup access code pool](/low-level-apis/smart-locks/access-codes/backup-access-codes) is empty. - ---- - -**`ttlock_lock_not_paired_to_gateway`** - -Indicates that the lock is not paired with a gateway. +Indicates that the [backup access code pool](https://docs.seam.co/low-level-apis/smart-locks/access-codes/backup-access-codes) is empty. --- @@ -2672,12 +2861,6 @@ Indicates that device credentials are missing. --- -**`lockly_missing_wifi_bridge`** - -Indicates that the Lockly lock is not connected to a Wi-Fi bridge. - ---- - **`hub_disconnected`** Indicates that the hub is disconnected. @@ -2710,7 +2893,7 @@ Indicates that the account is disconnected. **`bridge_disconnected`** -Indicates that the Seam API cannot communicate with [Seam Bridge](/capability-guides/seam-bridge), for example, if the Seam Bridge executable has stopped or if the computer running the Seam Bridge executable is offline. See also [Troubleshooting Your Access Control System](/low-level-apis/access-systems/troubleshooting-your-access-control-system). +Indicates that the Seam API cannot communicate with [Seam Bridge](https://docs.seam.co/capability-guides/seam-bridge), for example, if the Seam Bridge executable has stopped or if the computer running the Seam Bridge executable is offline. See also [Troubleshooting Your Access Control System](https://docs.seam.co/low-level-apis/access-systems/troubleshooting-your-access-control-system#acs_system.errors.seam_bridge_disconnected). --- @@ -2740,6 +2923,12 @@ Indicates that a hub or relay must be connected to unlock additional capabilitie --- +**`insufficient_permissions`** + +Indicates that the connected Kwikset account has member-level access to this lock's home. Admin or owner access is required to manage access codes and control the lock remotely. + +--- + **`keynest_unsupported_locker`** Indicates that the key is in a locker that does not support the access codes API. @@ -2776,6 +2965,18 @@ Indicates that the device is in power saving mode and may have limited functiona --- +**`provider_issue`** + +Indicates a provider-specific issue that may affect device functionality. + +--- + +**`salto_ks_lock_access_code_support_removed`** + +Indicates that a change in the reported device model has been detected for this Salto KS lock, which may occur after an IQ hub reset. Access code support may be affected. See https://help.getseam.com/articles/5098842588-salto-ks-lock-loses-access-code-support for troubleshooting steps. + +--- + **`salto_ks_office_mode`** Indicates that the Salto KS lock is in Office Mode. Access Codes will not unlock doors. @@ -2846,20 +3047,20 @@ Indicates that the Wyze Lock is not connected to a gateway. [**`/locks/configure_auto_lock`**](./configure_auto_lock) -Configures the auto-lock setting for a specified [lock](/low-level-apis/smart-locks). +Configures the auto-lock setting for a specified [lock](https://docs.seam.co/low-level-apis/smart-locks). [**`/locks/get`**](./get) -Returns a specified [lock](/low-level-apis/smart-locks). +Returns a specified [lock](https://docs.seam.co/low-level-apis/smart-locks). [**`/locks/list`**](./list) -Returns a list of all [locks](/low-level-apis/smart-locks). +Returns a list of all [locks](https://docs.seam.co/low-level-apis/smart-locks). [**`/locks/lock_door`**](./lock_door) -Locks a [lock](/low-level-apis/smart-locks). See also [Locking and Unlocking Smart Locks](/low-level-apis/smart-locks/lock-and-unlock). +Locks a [lock](https://docs.seam.co/low-level-apis/smart-locks). See also [Locking and Unlocking Smart Locks](https://docs.seam.co/low-level-apis/smart-locks/lock-and-unlock). [**`/locks/unlock_door`**](./unlock_door) -Unlocks a [lock](/low-level-apis/smart-locks). See also [Locking and Unlocking Smart Locks](/low-level-apis/smart-locks/lock-and-unlock). +Unlocks a [lock](https://docs.seam.co/low-level-apis/smart-locks). See also [Locking and Unlocking Smart Locks](https://docs.seam.co/low-level-apis/smart-locks/lock-and-unlock). diff --git a/mintlify-docs/api/locks/simulate/object.mdx b/mintlify-docs/api/locks/simulate/object.mdx index e9f45962e..11142e090 100644 --- a/mintlify-docs/api/locks/simulate/object.mdx +++ b/mintlify-docs/api/locks/simulate/object.mdx @@ -7,8 +7,8 @@ description: 'Use the lock simulation endpoints to mimic keypad code entry and m [**`/locks/simulate/keypad_code_entry`**](./keypad_code_entry) -Simulates the entry of a code on a keypad. You can only perform this action for [August](/device-and-system-integration-guides/august-locks) devices within [sandbox workspaces](/core-concepts/workspaces#sandbox-workspaces). +Simulates the entry of a code on a keypad. You can only perform this action for [August](https://docs.seam.co/device-and-system-integration-guides/august-locks) devices within [sandbox workspaces](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). [**`/locks/simulate/manual_lock_via_keypad`**](./manual_lock_via_keypad) -Simulates a manual lock action using a keypad. You can only perform this action for [August](/device-and-system-integration-guides/august-locks) devices within [sandbox workspaces](/core-concepts/workspaces#sandbox-workspaces). +Simulates a manual lock action using a keypad. You can only perform this action for [August](https://docs.seam.co/device-and-system-integration-guides/august-locks) devices within [sandbox workspaces](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). diff --git a/mintlify-docs/api/noise_sensors/object.mdx b/mintlify-docs/api/noise_sensors/object.mdx index c8b9df74e..ebd5eb2c1 100644 --- a/mintlify-docs/api/noise_sensors/object.mdx +++ b/mintlify-docs/api/noise_sensors/object.mdx @@ -5,7 +5,6 @@ description: 'Learn how the device object represents a noise sensor that measure ## The device Object for Noise Sensors - Represents a [noise sensor](/capability-guides/noise-sensors). Noise sensors are devices that measure that sound level in a given area. You can use noise sensors to monitor noise levels remotely and receive notifications when the noise volume is too loud. The Seam API enables you to configure the [noise thresholds](/capability-guides/noise-sensors/configure-noise-threshold-settings) of a noise sensor and receive events when a disturbance is detected. You can also [simulate triggering a noise threshold](/api/noise_sensors/simulate/trigger_noise_threshold). @@ -18,4 +17,4 @@ The Seam API represents a noise sensor as a `device` resource that includes both [**`/noise_sensors/list`**](./list) -Returns a list of all [noise sensors](/capability-guides/noise-sensors). +Returns a list of all [noise sensors](https://docs.seam.co/capability-guides/noise-sensors). diff --git a/mintlify-docs/api/noise_sensors/simulate/object.mdx b/mintlify-docs/api/noise_sensors/simulate/object.mdx index ab7283be5..35c3003b9 100644 --- a/mintlify-docs/api/noise_sensors/simulate/object.mdx +++ b/mintlify-docs/api/noise_sensors/simulate/object.mdx @@ -7,4 +7,4 @@ description: 'Use the noise sensor simulation endpoint to trigger a noise thresh [**`/noise_sensors/simulate/trigger_noise_threshold`**](./trigger_noise_threshold) -Simulates the triggering of a [noise threshold](/capability-guides/noise-sensors/configure-noise-threshold-settings) for a [noise sensor](/capability-guides/noise-sensors) in a [sandbox workspace](/core-concepts/workspaces#sandbox-workspaces). +Simulates the triggering of a [noise threshold](https://docs.seam.co/capability-guides/noise-sensors/configure-noise-threshold-settings) for a [noise sensor](https://docs.seam.co/capability-guides/noise-sensors) in a [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). diff --git a/mintlify-docs/api/phones/simulate/object.mdx b/mintlify-docs/api/phones/simulate/object.mdx index 58a0f2614..c744f5d83 100644 --- a/mintlify-docs/api/phones/simulate/object.mdx +++ b/mintlify-docs/api/phones/simulate/object.mdx @@ -7,4 +7,4 @@ description: 'Use the phone simulation endpoint to create a simulated phone in a [**`/phones/simulate/create_sandbox_phone`**](./create_sandbox_phone) -Creates a new simulated phone in a [sandbox workspace](/core-concepts/workspaces#sandbox-workspaces). See also [Creating a Simulated Phone for a User Identity](/capability-guides/mobile-access/developing-in-a-sandbox-workspace#creating-a-simulated-phone-for-a-user-identity). +Creates a new simulated phone in a [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). See also [Creating a Simulated Phone for a User Identity](https://docs.seam.co/capability-guides/mobile-access/developing-in-a-sandbox-workspace#creating-a-simulated-phone-for-a-user-identity). diff --git a/mintlify-docs/api/thermostats/object.mdx b/mintlify-docs/api/thermostats/object.mdx index 93087e6ef..cc9a32427 100644 --- a/mintlify-docs/api/thermostats/object.mdx +++ b/mintlify-docs/api/thermostats/object.mdx @@ -406,87 +406,127 @@ A thermostat device resource. **`can_configure_auto_lock`** _Boolean_ +Indicates whether the lock supports configuring automatic locking. + --- **`can_hvac_cool`** _Boolean_ +Indicates whether the thermostat supports cooling. + --- **`can_hvac_heat`** _Boolean_ +Indicates whether the thermostat supports heating. + --- **`can_hvac_heat_cool`** _Boolean_ +Indicates whether the thermostat supports simultaneous heating and cooling. + --- **`can_program_offline_access_codes`** _Boolean_ +Indicates whether the device supports programming offline access codes. + --- **`can_program_online_access_codes`** _Boolean_ +Indicates whether the device supports programming online access codes. + --- **`can_program_thermostat_programs_as_different_each_day`** _Boolean_ +Indicates whether the thermostat supports different climate programs for each day of the week. + --- **`can_program_thermostat_programs_as_same_each_day`** _Boolean_ +Indicates whether the thermostat supports a single climate program applied to every day. + --- **`can_program_thermostat_programs_as_weekday_weekend`** _Boolean_ +Indicates whether the thermostat supports weekday/weekend climate programs. + --- **`can_remotely_lock`** _Boolean_ +Indicates whether the device supports remote locking. + --- **`can_remotely_unlock`** _Boolean_ +Indicates whether the device supports remote unlocking. + --- **`can_run_thermostat_programs`** _Boolean_ +Indicates whether the thermostat supports running climate programs. + --- **`can_simulate_connection`** _Boolean_ +Indicates whether the device supports simulating connection in a sandbox. + --- **`can_simulate_disconnection`** _Boolean_ +Indicates whether the device supports simulating disconnection in a sandbox. + --- **`can_simulate_hub_connection`** _Boolean_ +Indicates whether the hub supports simulating connection in a sandbox. + --- **`can_simulate_hub_disconnection`** _Boolean_ +Indicates whether the hub supports simulating disconnection in a sandbox. + --- **`can_simulate_paid_subscription`** _Boolean_ +Indicates whether the device supports simulating a paid subscription in a sandbox. + --- **`can_simulate_removal`** _Boolean_ +Indicates whether the device supports simulating removal in a sandbox. + --- **`can_turn_off_hvac`** _Boolean_ +Indicates whether the thermostat can be turned off. + --- **`can_unlock_with_code`** _Boolean_ +Indicates whether the lock supports unlocking with an access code. + --- **`capabilities_supported`** _List_ _of Enums_ -Collection of capabilities that the device supports when connected to Seam. Values are `access_code`, which indicates that the device can manage and utilize digital PIN codes for secure access; `lock`, which indicates that the device controls a door locking mechanism, enabling the remote opening and closing of doors and other entry points; `noise_detection`, which indicates that the device supports monitoring and responding to ambient noise levels; `thermostat`, which indicates that the device can regulate and adjust indoor temperatures; `battery`, which indicates that the device can manage battery life and health; and `phone`, which indicates that the device is a mobile device, such as a smartphone. **Important:** Superseded by [capability flags](/capability-guides/device-and-system-capabilities#capability-flags). +Collection of capabilities that the device supports when connected to Seam. Values are `access_code`, which indicates that the device can manage and utilize digital PIN codes for secure access; `lock`, which indicates that the device controls a door locking mechanism, enabling the remote opening and closing of doors and other entry points; `noise_detection`, which indicates that the device supports monitoring and responding to ambient noise levels; `thermostat`, which indicates that the device can regulate and adjust indoor temperatures; `battery`, which indicates that the device can manage battery life and health; and `phone`, which indicates that the device is a mobile device, such as a smartphone. **Important:** Superseded by [capability flags](https://docs.seam.co/capability-guides/device-and-system-capabilities#capability-flags). --- @@ -504,7 +544,7 @@ Date and time at which the device object was created. **`custom_metadata`** _Record_ -Set of key:value pairs. Adding custom metadata to a resource, such as a [Connect Webview](/core-concepts/connect-webviews/attaching-custom-data-to-the-connect-webview), [connected account](/core-concepts/connected-accounts/adding-custom-metadata-to-a-connected-account), or [device](/core-concepts/devices/adding-custom-metadata-to-a-device), enables you to store custom information, like customer details or internal IDs from your application. +Set of key:value pairs. Adding custom metadata to a resource, such as a [Connect Webview](https://docs.seam.co/core-concepts/connect-webviews/attaching-custom-data-to-the-connect-webview), [connected account](https://docs.seam.co/core-concepts/connected-accounts/adding-custom-metadata-to-a-connected-account), or [device](https://docs.seam.co/core-concepts/devices/adding-custom-metadata-to-a-device), enables you to store custom information, like customer details or internal IDs from your application. --- @@ -546,7 +586,8 @@ Type of the device. - tedee_lock - akiles_lock - ultraloq_lock -- korelock_lock +- keyincode_lock +- omnitec_lock - keynest_key - noiseaware_activity_zone - minut_sensor @@ -599,7 +640,7 @@ Enum values: **`is_connected_account_error`** _Boolean_ -Indicates that the error is a [connected account](/api/connected_accounts/object) error. +Indicates that the error is a [connected account](https://docs.seam.co/api/connected_accounts/object) error. --- @@ -636,7 +677,44 @@ Enum values: **`is_connected_account_error`** _Boolean_ -Indicates that the error is a [connected account](/api/connected_accounts/object) error. +Indicates that the error is a [connected account](https://docs.seam.co/api/connected_accounts/object) error. + +--- + +**`is_device_error`** _Boolean_ + +Indicates that the error is not a device error. + +--- + +**`message`** _String_ + +Detailed description of the error. Provides insights into the issue and potentially how to rectify it. + + + + +Indicates that one or more dormakaba sites associated with the connected account could not be connected. Contact dormakaba support. + +**`created_at`** _Datetime_ + +Date and time at which Seam created the error. + +--- + +**`error_code`** _Enum_ + +Unique identifier of the type of error. Enables quick recognition and categorization of the issue. + +Enum values: + +- dormakaba_sites_disconnected + +--- + +**`is_connected_account_error`** _Boolean_ + +Indicates that the error is a [connected account](https://docs.seam.co/api/connected_accounts/object) error. --- @@ -777,7 +855,7 @@ Detailed description of the error. Provides insights into the issue and potentia -Indicates that the [backup access code pool](/low-level-apis/smart-locks/access-codes/backup-access-codes) is empty. +Indicates that the [backup access code pool](https://docs.seam.co/low-level-apis/smart-locks/access-codes/backup-access-codes) is empty. **`created_at`** _Datetime_ @@ -1025,7 +1103,7 @@ Detailed description of the error. Provides insights into the issue and potentia -Indicates that the Seam API cannot communicate with [Seam Bridge](/capability-guides/seam-bridge), for example, if the Seam Bridge executable has stopped or if the computer running the Seam Bridge executable is offline. See also [Troubleshooting Your Access Control System](/low-level-apis/access-systems/troubleshooting-your-access-control-system). +Indicates that the Seam API cannot communicate with [Seam Bridge](https://docs.seam.co/capability-guides/seam-bridge), for example, if the Seam Bridge executable has stopped or if the computer running the Seam Bridge executable is offline. See also [Troubleshooting Your Access Control System](https://docs.seam.co/low-level-apis/access-systems/troubleshooting-your-access-control-system#acs_system.errors.seam_bridge_disconnected). **`created_at`** _Datetime_ @@ -1045,7 +1123,7 @@ Enum values: **`is_bridge_error`** _Boolean_ -Indicates whether the error is related to [Seam Bridge](/capability-guides/seam-bridge). +Indicates whether the error is related to [Seam Bridge](https://docs.seam.co/capability-guides/seam-bridge). --- @@ -1065,7 +1143,7 @@ Detailed description of the error. Provides insights into the issue and potentia **`is_managed`** _Boolean_ -Indicates whether Seam manages the device. See also [Managed and Unmanaged Devices](/core-concepts/devices/managed-and-unmanaged-devices). +Indicates whether Seam manages the device. See also [Managed and Unmanaged Devices](https://docs.seam.co/core-concepts/devices/managed-and-unmanaged-devices). --- @@ -1438,6 +1516,31 @@ Enum values: - salto_ks_subscription_limit_almost_reached + + + +Indicates that a change in the reported device model has been detected for this Salto KS lock, which may occur after an IQ hub reset. Access code support may be affected. See https://help.getseam.com/articles/5098842588-salto-ks-lock-loses-access-code-support for troubleshooting steps. + +**`created_at`** _Datetime_ + +Date and time at which Seam created the warning. + +--- + +**`message`** _String_ + +Detailed description of the warning. Provides insights into the issue and potentially how to rectify it. + +--- + +**`warning_code`** _Enum_ + +Unique identifier of the type of warning. Enables quick recognition and categorization of the issue. + +Enum values: + +- salto_ks_lock_access_code_support_removed + @@ -1563,6 +1666,31 @@ Enum values: - hub_required_for_additional_capabilities + + + +Indicates a provider-specific issue that may affect device functionality. + +**`created_at`** _Datetime_ + +Date and time at which Seam created the warning. + +--- + +**`message`** _String_ + +Detailed description of the warning. Provides insights into the issue and potentially how to rectify it. + +--- + +**`warning_code`** _Enum_ + +Unique identifier of the type of warning. Enables quick recognition and categorization of the issue. + +Enum values: + +- provider_issue + @@ -1675,6 +1803,31 @@ Enum values: - max_access_codes_reached + + + +Indicates that the connected Kwikset account has member-level access to this lock's home. Admin or owner access is required to manage access codes and control the lock remotely. + +**`created_at`** _Datetime_ + +Date and time at which Seam created the warning. + +--- + +**`message`** _String_ + +Detailed description of the warning. Provides insights into the issue and potentially how to rectify it. + +--- + +**`warning_code`** _Enum_ + +Unique identifier of the type of warning. Enables quick recognition and categorization of the issue. + +Enum values: + +- insufficient_permissions + --- @@ -1689,7 +1842,7 @@ Unique identifier for the Seam workspace associated with the device. **`active_thermostat_schedule`** _Object_ -Active [thermostat schedule](/capability-guides/thermostats/creating-and-managing-thermostat-schedules). +Active [thermostat schedule](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules). **Deprecated**. Use `active_thermostat_schedule_id` with @@ -1699,43 +1852,43 @@ Active [thermostat schedule](/capability-guides/thermostats/creating-and-managin - Key of the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) to use for the [thermostat schedule](/capability-guides/thermostats/creating-and-managing-thermostat-schedules). + Key of the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) to use for the [thermostat schedule](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules). - Date and time at which the [thermostat schedule](/capability-guides/thermostats/creating-and-managing-thermostat-schedules) was created. + Date and time at which the [thermostat schedule](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules) was created. - ID of the desired [thermostat](/capability-guides/thermostats) device. + ID of the desired [thermostat](https://docs.seam.co/capability-guides/thermostats) device. - Date and time at which the [thermostat schedule](/capability-guides/thermostats/creating-and-managing-thermostat-schedules) ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. + Date and time at which the [thermostat schedule](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules) ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. - Errors associated with the [thermostat schedule](/capability-guides/thermostats/creating-and-managing-thermostat-schedules). + Errors associated with the [thermostat schedule](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules). Unique identifier of the type of error. Enables quick recognition and categorization of the issue. Detailed description of the error. Provides insights into the issue and potentially how to rectify it. - - Indicates whether a person at the thermostat can change the thermostat's settings after the [thermostat schedule](/capability-guides/thermostats/creating-and-managing-thermostat-schedules) starts. - - - Number of minutes for which a person at the thermostat can change the thermostat's settings after the activation of the scheduled [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets). See also [Specifying Manual Override Permissions](/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). - - - User-friendly name to identify the [thermostat schedule](/capability-guides/thermostats/creating-and-managing-thermostat-schedules). - - - Date and time at which the [thermostat schedule](/capability-guides/thermostats/creating-and-managing-thermostat-schedules) starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. - - - ID of the [thermostat schedule](/capability-guides/thermostats/creating-and-managing-thermostat-schedules). - - - ID of the [workspace](/core-concepts/workspaces) that contains the thermostat schedule. - + + + Indicates whether a person at the thermostat can change the thermostat's settings after the [thermostat schedule](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules) starts. + + + Number of minutes for which a person at the thermostat can change the thermostat's settings after the activation of the scheduled [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). + + + User-friendly name to identify the [thermostat schedule](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules). + + + Date and time at which the [thermostat schedule](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules) starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. + + + ID of the [thermostat schedule](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules). + + + ID of the workspace that contains the thermostat schedule. @@ -1743,7 +1896,7 @@ Active [thermostat schedule](/capability-guides/thermostats/creating-and-managin **`active_thermostat_schedule_id`** _UUID_ -ID of the active [thermostat schedule](/capability-guides/thermostats/creating-and-managing-thermostat-schedules). +ID of the active [thermostat schedule](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules). --- @@ -1783,9 +1936,9 @@ ASSA ABLOY Credential Service metadata for the phone. Indicated whether the endpoint is active. - - Indicates whether the credential service has active endpoints associated with the phone. - + + + Indicates whether the credential service has active endpoints associated with the phone. @@ -1843,41 +1996,38 @@ Climate preset modes that the thermostat supports, such as "home", "away", "wake **`available_climate_presets`** _List_ _of Objects_ -Available [climate presets](/capability-guides/thermostats/creating-and-managing-climate-presets) for the thermostat. +Available [climate presets](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) for the thermostat. can_delete Boolean -Indicates whether the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) key can be deleted. +Indicates whether the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) key can be deleted. can_edit Boolean -Indicates whether the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) key can be edited. +Indicates whether the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) key can be edited. can_use_with_thermostat_daily_programs Boolean -Indicates whether the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) key can be programmed in a thermostat daily program. +Indicates whether the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) key can be programmed in a thermostat daily program. climate_preset_key String -Unique key to identify the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets). +Unique key to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). climate_preset_mode Enum - -The climate preset mode for the thermostat, based on the available climate preset modes reported by the device. - - home @@ -1889,26 +2039,29 @@ The climate preset mode for the thermostat, based on the available climate prese + +The climate preset mode for the thermostat, based on the available climate preset modes reported by the device. + cooling_set_point_celsius Number -Temperature to which the thermostat should cool (in °C). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). +Temperature to which the thermostat should cool (in °C). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). cooling_set_point_fahrenheit Number -Temperature to which the thermostat should cool (in °F). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). +Temperature to which the thermostat should cool (in °F). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). display_name String -Display name for the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets). +Display name for the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). ecobee_metadata @@ -1935,9 +2088,6 @@ Indicates if the climate preset is optimized by Ecobee. ecobee_metadata.owner Enum - -Indicates whether the climate preset is owned by the user or the system. - - user @@ -1945,13 +2095,13 @@ Indicates whether the climate preset is owned by the user or the system. + +Indicates whether the climate preset is owned by the user or the system. + fan_mode_setting Enum - -Desired [fan mode setting](/capability-guides/thermostats/configure-current-climate-settings#fan-mode-settings), such as `on`, `auto`, or `circulate`. - - auto @@ -1960,27 +2110,27 @@ Desired [fan mode setting](/capability-guides/thermostats/configure-current-clim + +Desired [fan mode setting](https://docs.seam.co/capability-guides/thermostats/configure-current-climate-settings#fan-mode-settings), such as `on`, `auto`, or `circulate`. + heating_set_point_celsius Number -Temperature to which the thermostat should heat (in °C). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). +Temperature to which the thermostat should heat (in °C). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). heating_set_point_fahrenheit Number -Temperature to which the thermostat should heat (in °F). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). +Temperature to which the thermostat should heat (in °F). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). hvac_mode_setting Enum - -Desired [HVAC mode](/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. - - off @@ -1991,21 +2141,22 @@ Desired [HVAC mode](/capability-guides/thermostats/understanding-thermostat-conc + +Desired [HVAC mode](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. + manual_override_allowed Boolean -Indicates whether a person at the thermostat can change the thermostat's settings. See [Specifying Manual Override Permissions](/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). - -**Deprecated**. Use 'thermostat_schedule.is_override_allowed' +Indicates whether a person at the thermostat can change the thermostat's settings. See [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). name String -User-friendly name to identify the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets). +User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). @@ -2096,16 +2247,16 @@ Current climate setting. - Indicates whether the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) key can be deleted. + Indicates whether the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) key can be deleted. - Indicates whether the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) key can be edited. + Indicates whether the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) key can be edited. - Indicates whether the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) key can be programmed in a thermostat daily program. + Indicates whether the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) key can be programmed in a thermostat daily program. - Unique key to identify the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets). + Unique key to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). The climate preset mode for the thermostat, based on the available climate preset modes reported by the device. @@ -2122,13 +2273,13 @@ Current climate setting. - Temperature to which the thermostat should cool (in °C). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). + Temperature to which the thermostat should cool (in °C). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). - Temperature to which the thermostat should cool (in °F). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). + Temperature to which the thermostat should cool (in °F). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). - Display name for the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets). + Display name for the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). Metadata specific to the Ecobee climate, if applicable. @@ -2150,7 +2301,7 @@ Current climate setting. - Desired [fan mode setting](/capability-guides/thermostats/configure-current-climate-settings#fan-mode-settings), such as `on`, `auto`, or `circulate`. + Desired [fan mode setting](https://docs.seam.co/capability-guides/thermostats/configure-current-climate-settings#fan-mode-settings), such as `on`, `auto`, or `circulate`. @@ -2161,13 +2312,13 @@ Current climate setting. - Temperature to which the thermostat should heat (in °C). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). + Temperature to which the thermostat should heat (in °C). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). - Temperature to which the thermostat should heat (in °F). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). + Temperature to which the thermostat should heat (in °F). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). - Desired [HVAC mode](/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. + Desired [HVAC mode](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. @@ -2180,12 +2331,11 @@ Current climate setting. - Indicates whether a person at the thermostat can change the thermostat's settings. See [Specifying Manual Override Permissions](/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). - + Indicates whether a person at the thermostat can change the thermostat's settings. See [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). **Deprecated**. Use 'thermostat_schedule.is_override_allowed' - User-friendly name to identify the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets). + User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). @@ -2201,16 +2351,16 @@ Current climate setting. - Indicates whether the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) key can be deleted. + Indicates whether the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) key can be deleted. - Indicates whether the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) key can be edited. + Indicates whether the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) key can be edited. - Indicates whether the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) key can be programmed in a thermostat daily program. + Indicates whether the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) key can be programmed in a thermostat daily program. - Unique key to identify the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets). + Unique key to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). The climate preset mode for the thermostat, based on the available climate preset modes reported by the device. @@ -2227,13 +2377,13 @@ Current climate setting. - Temperature to which the thermostat should cool (in °C). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). + Temperature to which the thermostat should cool (in °C). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). - Temperature to which the thermostat should cool (in °F). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). + Temperature to which the thermostat should cool (in °F). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). - Display name for the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets). + Display name for the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). Metadata specific to the Ecobee climate, if applicable. @@ -2255,7 +2405,7 @@ Current climate setting. - Desired [fan mode setting](/capability-guides/thermostats/configure-current-climate-settings#fan-mode-settings), such as `on`, `auto`, or `circulate`. + Desired [fan mode setting](https://docs.seam.co/capability-guides/thermostats/configure-current-climate-settings#fan-mode-settings), such as `on`, `auto`, or `circulate`. @@ -2266,13 +2416,13 @@ Current climate setting. - Temperature to which the thermostat should heat (in °C). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). + Temperature to which the thermostat should heat (in °C). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). - Temperature to which the thermostat should heat (in °F). See also [Set Points](/capability-guides/thermostats/understanding-thermostat-concepts/set-points). + Temperature to which the thermostat should heat (in °F). See also [Set Points](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points). - Desired [HVAC mode](/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. + Desired [HVAC mode](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. @@ -2285,12 +2435,11 @@ Current climate setting. - Indicates whether a person at the thermostat can change the thermostat's settings. See [Specifying Manual Override Permissions](/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). - + Indicates whether a person at the thermostat can change the thermostat's settings. See [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). **Deprecated**. Use 'thermostat_schedule.is_override_allowed' - User-friendly name to identify the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets). + User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). @@ -2349,17 +2498,13 @@ Metadata for a dormakaba Oracode device. Prefix for a time slot for a dormakaba Oracode device. - - Site ID for a dormakaba Oracode device. - - **Deprecated**. Previously marked as "@DEPRECATED."- - - site_name - - String - - Site name for a dormakaba Oracode device. - + + + Site ID for a dormakaba Oracode device. + **Deprecated**. Previously marked as "@DEPRECATED." + + + Site name for a dormakaba Oracode device. @@ -2383,7 +2528,7 @@ Metadata for an ecobee device. **`fallback_climate_preset_key`** _String_ -Key of the [fallback climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets/setting-the-fallback-climate-preset) for the thermostat. +Key of the [fallback climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets/setting-the-fallback-climate-preset) for the thermostat. --- @@ -2668,25 +2813,25 @@ Metadata for a Lockly device. **`max_cooling_set_point_celsius`** _Number_ -Maximum [cooling set point](/capability-guides/thermostats/understanding-thermostat-concepts/set-points#cooling-set-point) in °C. +Maximum [cooling set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points#cooling-set-point) in °C. --- **`max_cooling_set_point_fahrenheit`** _Number_ -Maximum [cooling set point](/capability-guides/thermostats/understanding-thermostat-concepts/set-points#cooling-set-point) in °F. +Maximum [cooling set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points#cooling-set-point) in °F. --- **`max_heating_set_point_celsius`** _Number_ -Maximum [heating set point](/capability-guides/thermostats/understanding-thermostat-concepts/set-points#heating-set-point) in °C. +Maximum [heating set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points#heating-set-point) in °C. --- **`max_heating_set_point_fahrenheit`** _Number_ -Maximum [heating set point](/capability-guides/thermostats/understanding-thermostat-concepts/set-points#heating-set-point) in °F. +Maximum [heating set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points#heating-set-point) in °F. --- @@ -2704,37 +2849,37 @@ Maximum number of climate presets that the thermostat can support for weekly pro **`min_cooling_set_point_celsius`** _Number_ -Minimum [cooling set point](/capability-guides/thermostats/understanding-thermostat-concepts/set-points#cooling-set-point) in °C. +Minimum [cooling set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points#cooling-set-point) in °C. --- **`min_cooling_set_point_fahrenheit`** _Number_ -Minimum [cooling set point](/capability-guides/thermostats/understanding-thermostat-concepts/set-points#cooling-set-point) in °F. +Minimum [cooling set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points#cooling-set-point) in °F. --- **`min_heating_cooling_delta_celsius`** _Number_ -Minimum [temperature difference](/capability-guides/thermostats/understanding-thermostat-concepts/set-points#minimum-heating-cooling-temperature-delta) in °C between the cooling and heating set points when in heat-cool (auto) mode. +Minimum [temperature difference](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points#minimum-heating-cooling-temperature-delta) in °C between the cooling and heating set points when in heat-cool (auto) mode. --- **`min_heating_cooling_delta_fahrenheit`** _Number_ -Minimum [temperature difference](/capability-guides/thermostats/understanding-thermostat-concepts/set-points#minimum-heating-cooling-temperature-delta) in °F between the cooling and heating set points when in heat-cool (auto) mode. +Minimum [temperature difference](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points#minimum-heating-cooling-temperature-delta) in °F between the cooling and heating set points when in heat-cool (auto) mode. --- **`min_heating_set_point_celsius`** _Number_ -Minimum [heating set point](/capability-guides/thermostats/understanding-thermostat-concepts/set-points#heating-set-point) in °C. +Minimum [heating set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points#heating-set-point) in °C. --- **`min_heating_set_point_fahrenheit`** _Number_ -Minimum [heating set point](/capability-guides/thermostats/understanding-thermostat-concepts/set-points#heating-set-point) in °F. +Minimum [heating set point](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/set-points#heating-set-point) in °F. --- @@ -2922,6 +3067,31 @@ Metadata for a Nuki device. --- +**`omnitec_metadata`** _Object_ + +Metadata for an Omnitec device. + + + + + Whether the Omnitec lock has a connected gateway for remote operations. + + + Lock ID for an Omnitec device. + + + Bluetooth MAC address for an Omnitec device. + + + Lock name for an Omnitec device. + + + Static UTC offset of the Omnitec lock in milliseconds. Does not account for DST. + + + +--- + **`online`** _Boolean_ Indicates whether the device is online. @@ -3186,21 +3356,21 @@ Reported temperature in °F. **`temperature_threshold`** _Object_ -Current [temperature threshold](/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) set for the thermostat. +Current [temperature threshold](https://docs.seam.co/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) set for the thermostat. - Lower limit in °C within the current [temperature threshold](/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) set for the thermostat. + Lower limit in °C within the current [temperature threshold](https://docs.seam.co/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) set for the thermostat. - Lower limit in °F within the current [temperature threshold](/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) set for the thermostat. + Lower limit in °F within the current [temperature threshold](https://docs.seam.co/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) set for the thermostat. - Upper limit in °C within the current [temperature threshold](/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) set for the thermostat. + Upper limit in °C within the current [temperature threshold](https://docs.seam.co/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) set for the thermostat. - Upper limit in °F within the current [temperature threshold](/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) set for the thermostat. + Upper limit in °F within the current [temperature threshold](https://docs.seam.co/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) set for the thermostat. @@ -3214,7 +3384,7 @@ Precision of the thermostat's period in minutes. For example, if the thermostat **`thermostat_daily_programs`** _List_ _of Objects_ -Configured [daily programs](/capability-guides/thermostats/creating-and-managing-thermostat-programs) for the thermostat. +Configured [daily programs](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-programs) for the thermostat. created_at Datetime @@ -3238,37 +3408,31 @@ User-friendly name to identify the thermostat daily program. periods -List of Objects +List Array of thermostat daily program periods. - - Key of the [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) to activate at the `starts_at_time`. - - - Time at which the thermostat daily program period starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. + + thermostat_daily_program_id + +UUID - - thermostat_daily_program_id - - UUID +ID of the thermostat daily program. - ID of the thermostat daily program. + + workspace_id + +UUID - - workspace_id - - UUID +ID of the workspace that contains the thermostat daily program. - ID of the [workspace](/core-concepts/workspaces) that contains the thermostat daily program. - --- **`thermostat_weekly_program`** _Object_ -Current [weekly program](/capability-guides/thermostats/creating-and-managing-thermostat-programs) for the thermostat. +Current [weekly program](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-programs) for the thermostat. @@ -3342,6 +3506,9 @@ Metadata for a TTLock device. Lock ID for a TTLock device. + + Lock-side timezone offset in milliseconds east of UTC, as configured in the TTLock app. Source of truth for the lock's wall-clock interpretation of access code start/end times — a misconfigured value here is the typical cause of customer "codes offset by N hours" reports. Diagnostic only; Seam does not convert times based on this value. + Wireless keypads for a TTLock device. @@ -3448,12 +3615,6 @@ Indicates that the auxiliary heat is running. --- -**`ttlock_lock_not_paired_to_gateway`** - -Indicates that the lock is not paired with a gateway. - ---- - **`subscription_required`** Indicates that a subscription is required to connect. @@ -3466,12 +3627,6 @@ Indicates that device credentials are missing. --- -**`lockly_missing_wifi_bridge`** - -Indicates that the Lockly lock is not connected to a Wi-Fi bridge. - ---- - **`hub_disconnected`** Indicates that the hub is disconnected. @@ -3504,7 +3659,7 @@ Indicates that the account is disconnected. **`bridge_disconnected`** -Indicates that the Seam API cannot communicate with [Seam Bridge](/capability-guides/seam-bridge), for example, if the Seam Bridge executable has stopped or if the computer running the Seam Bridge executable is offline. See also [Troubleshooting Your Access Control System](/low-level-apis/access-systems/troubleshooting-your-access-control-system). +Indicates that the Seam API cannot communicate with [Seam Bridge](https://docs.seam.co/capability-guides/seam-bridge), for example, if the Seam Bridge executable has stopped or if the computer running the Seam Bridge executable is offline. See also [Troubleshooting Your Access Control System](https://docs.seam.co/low-level-apis/access-systems/troubleshooting-your-access-control-system#acs_system.errors.seam_bridge_disconnected). --- @@ -3522,18 +3677,6 @@ Indicates that the device has a flaky connection. --- -**`lockly_time_zone_not_configured`** - -Indicates that Seam detected that the Lockly device does not have a time zone configured. Time-bound codes may not work as expected. - ---- - -**`salto_ks_subscription_limit_almost_reached`** - -Indicates that the Salto KS site has exceeded 80% of the maximum number of allowed users. Increase your subscription limit or delete some users from your site. - ---- - **`scheduled_maintenance_window`** Indicates that a scheduled maintenance window has been detected. @@ -3552,83 +3695,59 @@ Indicates that a third-party integration has been detected. --- -**`ttlock_weak_gateway_signal`** - -Indicates that the gateway signal is weak. - ---- - -**`two_n_device_missing_timezone`** - -Indicates that the 2N device does not have a time zone configured. Configure a time zone on the device to enable access codes. - ---- - -**`ultraloq_time_zone_unknown`** - -Indicates that Seam does not know the time zone of the Ultraloq device. Set a time zone to enable time-bound access codes. - ---- - -**`wyze_device_missing_gateway`** - -Indicates that the Wyze Lock is not connected to a gateway. - ---- - ## Endpoints [**`/thermostats/activate_climate_preset`**](./activate_climate_preset) -Activates a specified [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) for a specified [thermostat](/capability-guides/thermostats). +Activates a specified [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) for a specified [thermostat](https://docs.seam.co/capability-guides/thermostats). [**`/thermostats/cool`**](./cool) -Sets a specified [thermostat](/capability-guides/thermostats) to [cool mode](/capability-guides/thermostats/configure-current-climate-settings). +Sets a specified [thermostat](https://docs.seam.co/capability-guides/thermostats) to [cool mode](https://docs.seam.co/capability-guides/thermostats/configure-current-climate-settings). [**`/thermostats/create_climate_preset`**](./create_climate_preset) -Creates a [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) for a specified [thermostat](/capability-guides/thermostats). +Creates a [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) for a specified [thermostat](https://docs.seam.co/capability-guides/thermostats). [**`/thermostats/delete_climate_preset`**](./delete_climate_preset) -Deletes a specified [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) for a specified [thermostat](/capability-guides/thermostats). +Deletes a specified [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) for a specified [thermostat](https://docs.seam.co/capability-guides/thermostats). [**`/thermostats/heat`**](./heat) -Sets a specified [thermostat](/capability-guides/thermostats) to [heat mode](/capability-guides/thermostats/configure-current-climate-settings). +Sets a specified [thermostat](https://docs.seam.co/capability-guides/thermostats) to [heat mode](https://docs.seam.co/capability-guides/thermostats/configure-current-climate-settings). [**`/thermostats/heat_cool`**](./heat_cool) -Sets a specified [thermostat](/capability-guides/thermostats) to [heat-cool ("auto") mode](/capability-guides/thermostats/configure-current-climate-settings). +Sets a specified [thermostat](https://docs.seam.co/capability-guides/thermostats) to [heat-cool ("auto") mode](https://docs.seam.co/capability-guides/thermostats/configure-current-climate-settings). [**`/thermostats/list`**](./list) -Returns a list of all [thermostats](/capability-guides/thermostats). +Returns a list of all [thermostats](https://docs.seam.co/capability-guides/thermostats). [**`/thermostats/off`**](./off) -Sets a specified [thermostat](/capability-guides/thermostats) to ["off" mode](/capability-guides/thermostats/configure-current-climate-settings). +Sets a specified [thermostat](https://docs.seam.co/capability-guides/thermostats) to ["off" mode](https://docs.seam.co/capability-guides/thermostats/configure-current-climate-settings). [**`/thermostats/set_fallback_climate_preset`**](./set_fallback_climate_preset) -Sets a specified [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) as the ["fallback"](/capability-guides/thermostats/creating-and-managing-climate-presets/setting-the-fallback-climate-preset) preset for a specified [thermostat](/capability-guides/thermostats). +Sets a specified [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) as the ["fallback"](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets/setting-the-fallback-climate-preset) preset for a specified [thermostat](https://docs.seam.co/capability-guides/thermostats). [**`/thermostats/set_fan_mode`**](./set_fan_mode) -Sets the [fan mode setting](/capability-guides/thermostats/configure-current-climate-settings#fan-mode-settings) for a specified [thermostat](/capability-guides/thermostats). +Sets the [fan mode setting](https://docs.seam.co/capability-guides/thermostats/configure-current-climate-settings#fan-mode-settings) for a specified [thermostat](https://docs.seam.co/capability-guides/thermostats). [**`/thermostats/set_hvac_mode`**](./set_hvac_mode) -Sets the [HVAC mode](/capability-guides/thermostats/configure-current-climate-settings) for a specified [thermostat](/capability-guides/thermostats). +Sets the [HVAC mode](https://docs.seam.co/capability-guides/thermostats/configure-current-climate-settings) for a specified [thermostat](https://docs.seam.co/capability-guides/thermostats). [**`/thermostats/set_temperature_threshold`**](./set_temperature_threshold) -Sets a [temperature threshold](/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) for a specified thermostat. Seam emits a `thermostat.temperature_threshold_exceeded` event and adds a warning on a thermostat if it reports a temperature outside the threshold range. +Sets a [temperature threshold](https://docs.seam.co/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) for a specified thermostat. Seam emits a `thermostat.temperature_threshold_exceeded` event and adds a warning on a thermostat if it reports a temperature outside the threshold range. [**`/thermostats/update_climate_preset`**](./update_climate_preset) -Updates a specified [climate preset](/capability-guides/thermostats/creating-and-managing-climate-presets) for a specified [thermostat](/capability-guides/thermostats). +Updates a specified [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) for a specified [thermostat](https://docs.seam.co/capability-guides/thermostats). [**`/thermostats/update_weekly_program`**](./update_weekly_program) diff --git a/mintlify-docs/api/thermostats/simulate/object.mdx b/mintlify-docs/api/thermostats/simulate/object.mdx index ad1c7b8b2..0a0fbf06e 100644 --- a/mintlify-docs/api/thermostats/simulate/object.mdx +++ b/mintlify-docs/api/thermostats/simulate/object.mdx @@ -7,8 +7,8 @@ description: 'Use the thermostat simulation endpoints to mimic HVAC mode changes [**`/thermostats/simulate/hvac_mode_adjusted`**](./hvac_mode_adjusted) -Simulates having adjusted the [HVAC mode](/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) for a [thermostat](/capability-guides/thermostats). Only applicable for [sandbox devices](/core-concepts/workspaces#sandbox-workspaces). See also [Testing Your Thermostat App with Simulate Endpoints](/capability-guides/thermostats/testing-your-thermostat-app-with-simulate-endpoints). +Simulates having adjusted the [HVAC mode](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) for a [thermostat](https://docs.seam.co/capability-guides/thermostats). Only applicable for [sandbox devices](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). See also [Testing Your Thermostat App with Simulate Endpoints](https://docs.seam.co/capability-guides/thermostats/testing-your-thermostat-app-with-simulate-endpoints). [**`/thermostats/simulate/temperature_reached`**](./temperature_reached) -Simulates a [thermostat](/capability-guides/thermostats) reaching a specified temperature. Only applicable for [sandbox devices](/core-concepts/workspaces#sandbox-workspaces). See also [Testing Your Thermostat App with Simulate Endpoints](/capability-guides/thermostats/testing-your-thermostat-app-with-simulate-endpoints). +Simulates a [thermostat](https://docs.seam.co/capability-guides/thermostats) reaching a specified temperature. Only applicable for [sandbox devices](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). See also [Testing Your Thermostat App with Simulate Endpoints](https://docs.seam.co/capability-guides/thermostats/testing-your-thermostat-app-with-simulate-endpoints). diff --git a/mintlify-docs/api/user_identities/unmanaged/object.mdx b/mintlify-docs/api/user_identities/unmanaged/object.mdx index 262d557f5..9c4e597c5 100644 --- a/mintlify-docs/api/user_identities/unmanaged/object.mdx +++ b/mintlify-docs/api/user_identities/unmanaged/object.mdx @@ -7,12 +7,14 @@ description: 'Use the unmanaged user identity endpoints to get, list, and update [**`/user_identities/unmanaged/get`**](./get) -Returns a specified unmanaged [user identity](/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity) (where is_managed = false). +Returns a specified unmanaged [user identity](https://docs.seam.co/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity) (where is_managed = false). [**`/user_identities/unmanaged/list`**](./list) -Returns a list of all unmanaged [user identities](/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity) (where is_managed = false). +Returns a list of all unmanaged [user identities](https://docs.seam.co/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity) (where is_managed = false). [**`/user_identities/unmanaged/update`**](./update) -Updates an unmanaged [user identity](/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity) to make it managed. +Updates an unmanaged [user identity](https://docs.seam.co/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity) to make it managed. + +This endpoint can only be used to convert unmanaged user identities to managed ones by setting `is_managed` to `true`. It cannot be used to convert managed user identities back to unmanaged. diff --git a/mintlify-docs/api/workspaces/object.mdx b/mintlify-docs/api/workspaces/object.mdx index bef39f41f..c9afa45dc 100644 --- a/mintlify-docs/api/workspaces/object.mdx +++ b/mintlify-docs/api/workspaces/object.mdx @@ -3,7 +3,6 @@ title: 'The Workspace Object' description: 'Learn how the workspace object represents an isolated Seam environment that holds your devices, connected accounts, and resources, with sandbox support.' --- - ## The workspace Object Represents a Seam [workspace](https://docs.seam.co/core-concepts/workspaces). A workspace is a top-level entity that encompasses all other resources below it, such as devices, connected accounts, and Connect Webviews. Seam provides two types of workspaces. A [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces) is a special type of workspace designed for testing code. Sandbox workspaces offer test device accounts and virtual devices that you can connect and control. This ability to work with virtual devices is quite handy because it removes the need to own physical devices from multiple brands. To connect real devices and systems to Seam, use a [production workspace](https://docs.seam.co/core-concepts/workspaces#production-workspaces). diff --git a/package.json b/package.json index c71f7d8df..efb84f9ac 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "format": "prettier --write --ignore-path .prettierignore .", "preformat": "eslint --fix .", "format-code-sample-tabs": "tsx codegen/format-code-sample-tabs.ts", - "generate:mintlify": "tsx mintlify-codegen/generate.ts", + "generate:mintlify": "tsx mintlify-codegen/smith.ts", "sync:logos": "tsx mintlify-codegen/sync-logos.ts", "inject:device-lists": "tsx mintlify-codegen/inject-device-list.ts", "validate:mintlify": "cd mintlify-docs && npx @mintlify/cli@latest validate 2>&1 | tsx ../mintlify-codegen/filter-validate-output.ts",