diff --git a/.claude/hooks/openapi-skill-auto-inject.sh b/.claude/hooks/openapi-skill-auto-inject.sh new file mode 100755 index 0000000..c234268 --- /dev/null +++ b/.claude/hooks/openapi-skill-auto-inject.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# UserPromptSubmit hook that auto-injects generate-openapi-from-pr skill instruction +# when user provides an intercom/intercom PR URL or asks to generate OpenAPI docs from a PR + +# Read hook event data from stdin +hook_data=$(cat) + +# Extract user message from the JSON +user_message=$(echo "$hook_data" | jq -r '.prompt // empty' 2>/dev/null) + +# Check if user message contains intercom PR references or OpenAPI generation keywords +if echo "$user_message" | grep -qiE 'intercom/intercom(/pull/|#)[0-9]+|generate.*(openapi|open-api|spec).*(from|pr)|openapi.*(from|pr)|document.*(this|api).*(pr|change)'; then + cat <<'EOF' +{ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": "OPENAPI: The user wants to generate OpenAPI spec changes from an intercom PR. Use the generate-openapi-from-pr skill to handle this request." + } +} +EOF +else + # No match - no additional context + exit 0 +fi diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0fbab6e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/openapi-skill-auto-inject.sh" + } + ] + } + ] + } +} diff --git a/.claude/skills/generate-openapi-from-pr/SKILL.md b/.claude/skills/generate-openapi-from-pr/SKILL.md new file mode 100644 index 0000000..e72d919 --- /dev/null +++ b/.claude/skills/generate-openapi-from-pr/SKILL.md @@ -0,0 +1,216 @@ +--- +name: generate-openapi-from-pr +description: > + Generate OpenAPI spec changes from an intercom monolith PR. Use this skill whenever a user + provides an intercom/intercom PR URL or number, asks to generate or update OpenAPI docs, + update the spec, document an API change, or mentions shipping API documentation from a PR. + Also trigger when the user pastes a github.com/intercom/intercom/pull/ URL even without + explicit instructions — they almost certainly want spec changes generated. This is the + primary workflow for this repository. + +metadata: + author: team-data-foundations + version: "1.0" + user-invocable: true + argument-hint: "" + +allowed-tools: Task, Read, Glob, Grep, Write, Edit, Bash, AskUserQuestion +--- + +# Generate OpenAPI Spec from Intercom PR + +This skill takes an intercom monolith PR (from `intercom/intercom`) and generates the corresponding OpenAPI spec changes in this repo (`Intercom-OpenAPI`). + +## Workflow + +### Step 1: Parse Input + +Extract the PR number from the user's input. Accept: +- Full URL: `https://github.com/intercom/intercom/pull/12345` +- Short reference: `intercom/intercom#12345` +- Just a number: `12345` (assume intercom/intercom) + +### Step 2: Fetch PR Details + +```bash +# Get PR description, metadata, and state +gh pr view --repo intercom/intercom --json title,body,files,labels,state + +# Get the full diff (works for open and merged PRs) +gh pr diff --repo intercom/intercom +``` + +If the diff is too large, fetch individual changed files instead: +```bash +gh pr view --repo intercom/intercom --json files --jq '.files[].path' +``` + +Then fetch specific files of interest (controllers, models, version changes, routes). + +For merged PRs where you need the full file (not just diff), fetch from the default branch: +```bash +gh api repos/intercom/intercom/contents/ --jq '.content' | base64 -d +``` + +### Step 3: Analyze the Diff + +Scan the diff for these file patterns and extract API-relevant information: + +#### 3a. Controllers (`app/controllers/api/v3/`) + +Look for: +- **New controller files** → new API resource with endpoints +- **New actions** (`def index`, `def show`, `def create`, `def update`, `def destroy`) → new operations +- **`requires_version_change`** → which version change gates this endpoint +- **`render_json Api::V3::Models::XxxResponse`** → identifies the response model/presenter +- **`params.slice(...).permit(...)`** or **request parser classes** (`RequestParser`, `StrongParams`) → request body fields +- **Error handling** (`raise Api::V3::Errors::ApiCodedError`) → error responses +- **`before_action :check_api_version!`** → version-gated endpoint + +#### 3b. Models/Presenters (`app/presenters/api/v3/` or `app/lib/api/v3/models/`) + +Look for: +- **`serialized_attributes do`** blocks → response schema properties +- **`attribute :field_name`** → schema field definition +- **`stringify: true`** → field is string type (even if integer in DB) +- **`from_model` method** → how the model maps from internal objects +- **Conditional attributes** based on version → version-specific fields + +#### 3c. Version Changes (`app/lib/api/versioning/changes/`) + +Look for: +- **`define_description`** → description of the API change (use in PR/commit message) +- **`define_is_breaking`** → whether this is a breaking change +- **`define_is_ready_for_release`** → usually `false` for new changes +- **`define_transformation ... data.except(:field1, :field2)`** → these fields are NEW (removed for old versions) +- **`define_transformation` with data modification** → field format/value changed between versions + +#### 3d. Version Registration (`app/lib/api/versioning/service.rb`) + +Look for which version block the new change is added to: +- `UnstableVersion.new(changes: [...])` → goes in Unstable (version `0/`) +- `Version.new(id: "2.15", changes: [...])` → goes in that specific version + +#### 3e. Routes (`config/routes/api_v3.rb`) + +Look for: +- `resources :things` → standard CRUD: index, show, create, update, destroy +- `resources :things, only: [:index, :show]` → limited operations +- `member do ... end` → actions on specific resource (e.g., PUT `/things/{id}/action`) +- `collection do ... end` → actions on resource collection (e.g., POST `/things/search`) +- Nested resources → parent/child paths (e.g., `/contacts/{id}/tags`) + +#### 3f. OAuth Scopes (`app/lib/policy/api_controller_routes_oauth_scope_policy.rb`) + +Look for scope mappings to understand required auth scope for the endpoint. + +### Step 4: Ask User for Version Targeting + +Present the findings and ask: + +``` +I found the following API changes in PR #XXXXX: +- [list of changes found] + +Which API versions should I update? +- Unstable only (default for new features) +- Specific versions (for bug fixes/backports) +``` + +Default to Unstable (`descriptions/0/api.intercom.io.yaml`) unless the PR clearly targets specific versions. + +### Step 5: Read Target Spec File(s) + +Read the target spec file(s) to understand: +- Existing endpoints in the same resource group (for consistent naming/style) +- Existing schemas that can be reused or extended +- The `intercom_version` enum (to verify version values) +- Where to insert new paths/schemas (maintain alphabetical or logical grouping) +- **All inline examples that reference the affected schema** — when adding a field, you must update every response example that returns that schema. Search with: `grep -n 'schemas/' ` +- **Existing example values** for the same resource — reuse the same style of IDs, workspace IDs, timestamps, and names that nearby endpoints use. Consistency matters more than novelty. + +### Step 6: Generate OpenAPI Changes + +Read the appropriate reference file based on what the PR changes: + +- **Adding/modifying fields or schemas?** → Read [./ruby-to-openapi-mapping.md](./ruby-to-openapi-mapping.md) for how Ruby presenter attributes map to OpenAPI types +- **Adding new endpoints?** → Read [./openapi-patterns.md](./openapi-patterns.md) for concrete YAML templates (GET, POST, PUT, DELETE, search) +- **Updating multiple versions?** → Read [./version-propagation.md](./version-propagation.md) for the decision tree on which files to update + +#### The two most important rules + +**Rule 1: Field additions require updates in TWO places.** When adding a field to a schema, you must update both the schema definition in `components/schemas` AND every inline response example that returns that schema. Find all affected examples with: +```bash +grep -n 'schemas/' descriptions/0/api.intercom.io.yaml +``` + +**Rule 2: New resources need a top-level tag.** If adding an entirely new API resource, add an entry to the `tags` array at the bottom of the spec (alphabetical order). The tag name must match the `tags` on endpoints and `x-tags` on schemas. See existing tags in [./openapi-patterns.md](./openapi-patterns.md) under "Top-Level Tags". + +#### Quick checklist for new endpoints + +Every endpoint needs: `summary`, `description`, `operationId` (unique, camelCase), `tags`, `Intercom-Version` header parameter (`"$ref": "#/components/schemas/intercom_version"`), response with inline examples + schema `$ref`, and at minimum a `401 Unauthorized` error response. POST/PUT endpoints also need a `requestBody` with schema and examples. See [./openapi-patterns.md](./openapi-patterns.md) for complete templates. + +**Writing good descriptions:** Extract the description from the PR's version change `define_description` if available — it's usually well-written for the changelog. Supplement with details from the controller (constraints, validations, edge cases). A good description explains what the endpoint does AND when you'd use it, not just "You can do X." + +**Response example detail level:** Match the verbosity of existing examples for the same schema. If other ticket endpoints show a full ticket object with nested `ticket_parts`, `contacts`, and `linked_objects`, your example should too. If they're minimal (just `type` and `id`), keep yours minimal. Look at the nearest sibling endpoint for the right level of detail. + +#### Quick checklist for new schemas + +Every schema needs: `title` (Title Case), `type: object`, `x-tags`, `description`, and `properties` where each property has `type`, `description`, and `example`. Mark nullable fields explicitly with `nullable: true`. Timestamps use `type: integer` + `format: date-time`. + +### Step 7: Apply Changes + +Use the Edit tool to insert changes into the spec file(s). Be careful about: +- YAML indentation (2-space indent throughout) +- Inserting paths in logical order (group related endpoints together) +- Inserting schemas alphabetically in `components/schemas` +- Adding new top-level tags in alphabetical order in the `tags` array +- Not breaking existing content + +### Step 8: Validate + +Run Fern validation: +```bash +fern check +``` + +If `fern` is not installed, fall back to YAML syntax validation: +```bash +python3 -c "import yaml; yaml.safe_load(open('descriptions/0/api.intercom.io.yaml'))" && echo "YAML valid" +``` + +If validation fails, read the error output and fix the issues. Common problems: indentation errors, missing quotes on string values that look like numbers, and duplicate keys. + +### Step 9: Summarize + +Report to the user: +- What was added/changed (new endpoints, new schemas, new fields, new top-level tags) +- Which files were modified +- Which versions were updated +- Any manual follow-up needed + +#### Follow-up Checklist + +Always remind the user of remaining manual steps: + +1. **Review generated changes** for accuracy against the actual API behavior +2. **Fern overrides** — if new endpoints were added to Unstable, check if `fern/unstable-openapi-overrides.yml` needs SDK method name entries +3. **Developer-docs PR** — copy the updated spec to the `developer-docs` repo: + - Copy `descriptions/0/api.intercom.io.yaml` → `docs/references/@Unstable/rest-api/api.intercom.io.yaml` + - For stable versions: `descriptions/2.15/api.intercom.io.yaml` → `docs/references/@2.15/rest-api/api.intercom.io.yaml` +4. **Changelog** — if the change should appear in the public changelog, update `docs/references/@/changelog.md` in the developer-docs repo (newest entries at top) +5. **Cross-version changes** — if this is an unversioned change (affects all versions), also update `docs/build-an-integration/learn-more/rest-apis/unversioned-changes.md` in developer-docs +6. **Run `fern check`** to validate before committing + +## Important Notes + +- **Do NOT run `fern generate` without `--preview`** — this would auto-submit PRs to SDK repos +- **Match existing examples** — before writing new example values, look at how nearby endpoints for the same resource format their examples. Reuse the same style of IDs (`'494'` not `'1'`), workspace IDs (`this_is_an_id664_that_should_be_at_least_`), timestamps (recent UNIX timestamps like `1719493065`), and names. Consistency across the spec is more important than creativity. +- **Match existing style** — look at nearby endpoints for naming, formatting, and level of detail in response examples. If sibling endpoints show full nested objects, yours should too. +- **Extract descriptions from the PR** — the version change's `define_description` is usually well-written. Use it as the basis for your endpoint description, then enrich with constraints and edge cases from the controller code. +- **Cross-reference with existing schemas** — reuse `$ref` to existing schemas wherever possible +- **Nullable fields** — always explicitly mark with `nullable: true` +- **The `error` schema** is already defined — always reference it with `"$ref": "#/components/schemas/error"` +- **Top-level tags** — new resources need a tag in the `tags` array at the bottom of the spec +- **Servers** — the spec already defines 3 regional servers (US, EU, AU) — do not modify +- **Security** — global `bearerAuth` is already configured — do not modify diff --git a/.claude/skills/generate-openapi-from-pr/openapi-patterns.md b/.claude/skills/generate-openapi-from-pr/openapi-patterns.md new file mode 100644 index 0000000..2e99749 --- /dev/null +++ b/.claude/skills/generate-openapi-from-pr/openapi-patterns.md @@ -0,0 +1,707 @@ +# OpenAPI YAML Patterns + +Concrete templates extracted from the actual Intercom OpenAPI spec. Use these as the basis for generating new endpoints and schemas. + +## Endpoint Patterns + +### GET List Endpoint + +```yaml +"/tags": + get: + summary: List all tags + parameters: + - name: Intercom-Version + in: header + schema: + "$ref": "#/components/schemas/intercom_version" + tags: + - Tags + operationId: listTags + description: "You can fetch a list of all tags for a given workspace.\n\n" + responses: + '200': + description: successful + content: + application/json: + examples: + successful: + value: + type: list + data: + - type: tag + id: '102' + name: Manual tag 1 + schema: + "$ref": "#/components/schemas/tag_list" + '401': + description: Unauthorized + content: + application/json: + examples: + Unauthorized: + value: + type: error.list + request_id: 2859da57-c83f-405c-8166-240a312442a3 + errors: + - code: unauthorized + message: Access Token Invalid + schema: + "$ref": "#/components/schemas/error" +``` + +### GET List with Pagination + +```yaml +"/contacts": + get: + summary: List all contacts + parameters: + - name: Intercom-Version + in: header + schema: + "$ref": "#/components/schemas/intercom_version" + - name: page + in: query + description: The page of results to fetch. + schema: + type: integer + example: 1 + - name: per_page + in: query + description: The number of results per page. + schema: + type: integer + example: 50 + tags: + - Contacts + operationId: ListContacts + description: | + You can fetch a list of all contacts (ie. users or leads) in your workspace. + {% admonition type="warning" name="Pagination" %} + You can use pagination to limit the number of results returned. + {% /admonition %} + responses: + '200': + description: successful + content: + application/json: + examples: + successful: + value: + type: list + data: [] + total_count: 0 + pages: + type: pages + page: 1 + per_page: 50 + total_pages: 0 + schema: + "$ref": "#/components/schemas/contact_list" + '401': + description: Unauthorized + content: + application/json: + examples: + Unauthorized: + value: + type: error.list + request_id: e097e446-9ae6-44a8-8e13-2bf3008b87ef + errors: + - code: unauthorized + message: Access Token Invalid + schema: + "$ref": "#/components/schemas/error" +``` + +### GET by ID Endpoint + +```yaml +"/tags/{id}": + get: + summary: Find a specific tag + parameters: + - name: Intercom-Version + in: header + schema: + "$ref": "#/components/schemas/intercom_version" + - name: id + in: path + description: The unique identifier of a given tag + example: '123' + required: true + schema: + type: string + tags: + - Tags + operationId: findTag + description: | + You can fetch the details of tags that are on the workspace by their id. + This will return a tag object. + responses: + '200': + description: Tag found + content: + application/json: + examples: + Tag found: + value: + type: tag + id: '113' + name: Manual tag + schema: + "$ref": "#/components/schemas/tag_basic" + '404': + description: Tag not found + content: + application/json: + examples: + Tag not found: + value: + type: error.list + request_id: e20c89d2-29c6-4abb-aa3d-c860e1cec1ca + errors: + - code: not_found + message: Resource Not Found + schema: + "$ref": "#/components/schemas/error" + '401': + description: Unauthorized + content: + application/json: + examples: + Unauthorized: + value: + type: error.list + request_id: f230e3a7-00a9-456b-bf1c-2ad4b7dc49f6 + errors: + - code: unauthorized + message: Access Token Invalid + schema: + "$ref": "#/components/schemas/error" +``` + +### POST Create Endpoint + +```yaml +"/contacts": + post: + summary: Create contact + parameters: + - name: Intercom-Version + in: header + schema: + "$ref": "#/components/schemas/intercom_version" + tags: + - Contacts + operationId: CreateContact + description: You can create a new contact (ie. user or lead). + responses: + '200': + description: successful + content: + application/json: + examples: + successful: + value: + type: contact + id: 6762f0dd1bb69f9f2193bb83 + email: joebloggs@intercom.io + created_at: 1734537437 + schema: + "$ref": "#/components/schemas/contact" + '401': + description: Unauthorized + content: + application/json: + examples: + Unauthorized: + value: + type: error.list + request_id: ff2353d3-d3d6-4f20-8268-847869d01e73 + errors: + - code: unauthorized + message: Access Token Invalid + schema: + "$ref": "#/components/schemas/error" + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/create_contact_request" + examples: + successful: + summary: successful + value: + email: joebloggs@intercom.io +``` + +### PUT Update Endpoint + +```yaml +"/admins/{id}/away": + put: + summary: Set an admin to away + parameters: + - name: Intercom-Version + in: header + schema: + "$ref": "#/components/schemas/intercom_version" + - name: id + in: path + required: true + description: The unique identifier of a given admin + schema: + type: integer + tags: + - Admins + operationId: setAwayAdmin + description: You can set an Admin as away for the Inbox. + responses: + '200': + description: Successful response + content: + application/json: + examples: + Successful response: + value: + type: admin + id: '991267460' + name: Ciaran2 Lee + email: admin2@email.com + away_mode_enabled: true + away_mode_reassign: true + has_inbox_seat: true + schema: + "$ref": "#/components/schemas/admin" + '404': + description: Admin not found + content: + application/json: + examples: + Admin not found: + value: + type: error.list + request_id: efcd0531-798b-4c22-bccd-68877ed7faa4 + errors: + - code: admin_not_found + message: Admin for admin_id not found + schema: + "$ref": "#/components/schemas/error" + requestBody: + content: + application/json: + schema: + type: object + required: + - away_mode_enabled + - away_mode_reassign + properties: + away_mode_enabled: + type: boolean + description: Set to "true" to change the status of the admin to away. + example: true + away_mode_reassign: + type: boolean + description: Set to "true" to assign new replies to default inbox. + example: false + examples: + successful_response: + summary: Successful response + value: + away_mode_enabled: true + away_mode_reassign: true +``` + +### DELETE Endpoint + +```yaml +"/tags/{id}": + delete: + summary: Delete tag + parameters: + - name: Intercom-Version + in: header + schema: + "$ref": "#/components/schemas/intercom_version" + - name: id + in: path + description: The unique identifier of a given tag + example: '123' + required: true + schema: + type: string + tags: + - Tags + operationId: deleteTag + description: You can delete tags by passing in the id. + responses: + '200': + description: Successful + '404': + description: Resource not found + content: + application/json: + examples: + Resource not found: + value: + type: error.list + request_id: 49536975-bbc5-4a2f-ab8b-7928275cb4d3 + errors: + - code: not_found + message: Resource Not Found + schema: + "$ref": "#/components/schemas/error" + '401': + description: Unauthorized + content: + application/json: + examples: + Unauthorized: + value: + type: error.list + request_id: a3e5b8e2-1234-5678-9abc-def012345678 + errors: + - code: unauthorized + message: Access Token Invalid + schema: + "$ref": "#/components/schemas/error" +``` + +### POST Search Endpoint + +```yaml +"/conversations/search": + post: + summary: Search conversations + parameters: + - name: Intercom-Version + in: header + schema: + "$ref": "#/components/schemas/intercom_version" + tags: + - Conversations + operationId: searchConversations + description: | + You can search for multiple conversations by the value of their attributes. + responses: + '200': + description: successful + content: + application/json: + examples: + successful: + value: + type: conversation.list + conversations: [] + total_count: 0 + pages: + type: pages + page: 1 + per_page: 20 + total_pages: 0 + schema: + "$ref": "#/components/schemas/conversation_list" + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/search_request" + examples: + successful: + summary: successful + value: + query: + operator: AND + value: + - field: created_at + operator: ">" + value: '1306054154' +``` + +--- + +## Schema Patterns + +### Simple Object Schema + +```yaml +tag_basic: + title: Tag + type: object + x-tags: + - Tags + description: A tag allows you to label your contacts, companies, and conversations. + properties: + type: + type: string + description: value is "tag" + example: tag + id: + type: string + description: The id of the tag. + example: '123' + name: + type: string + description: The name of the tag. + example: Test tag +``` + +### Schema with Nullable Fields + +```yaml +tag: + title: Tag + type: object + x-tags: + - Tags + description: A tag allows you to label your contacts, companies, and conversations. + properties: + type: + type: string + description: value is "tag" + example: tag + id: + type: string + description: The id of the tag. + example: '123' + name: + type: string + description: The name of the tag. + example: Test tag + applied_at: + type: integer + format: date-time + nullable: true + description: The time when the tag was applied to the object. + example: 1663597223 + applied_by: + type: object + nullable: true + description: The admin who applied the tag. + allOf: + - "$ref": "#/components/schemas/reference" +``` + +### List/Collection Schema + +```yaml +tag_list: + title: Tags + type: object + description: A list of tags objects in the workspace. + properties: + type: + type: string + description: The type of the object + enum: + - list + example: list + data: + type: array + description: A list of tags objects + items: + "$ref": "#/components/schemas/tag" +``` + +### Request Body Schema + +```yaml +create_or_update_tag_request: + title: Create or Update Tag Request Payload + type: object + description: You can create or update an existing tag. + required: + - name + properties: + name: + type: string + description: The name of the tag, which will be created if not found. + example: Independent + id: + type: string + description: The id of tag to updates. + example: '656452352' +``` + +### Schema with Enum + +```yaml +activity_log: + title: Activity Log + type: object + description: Activities performed by Admins. + nullable: true + properties: + activity_type: + type: string + enum: + - admin_away_mode_change + - admin_deletion + - admin_login_success + - app_name_change + example: app_name_change +``` + +### Nested Reference (allOf pattern) + +Use `allOf` when referencing another schema as a nullable property: + +```yaml +properties: + applied_by: + type: object + nullable: true + description: The admin who applied the tag. + allOf: + - "$ref": "#/components/schemas/reference" +``` + +Direct `$ref` when the field is not nullable: +```yaml +properties: + assignee: + "$ref": "#/components/schemas/admin" +``` + +### Paginated List Schema + +```yaml +contact_list: + title: Contact List + type: object + description: A list of contacts. + properties: + type: + type: string + enum: + - list + example: list + data: + type: array + items: + "$ref": "#/components/schemas/contact" + total_count: + type: integer + description: Total number of contacts. + example: 100 + pages: + "$ref": "#/components/schemas/cursor_pages" +``` + +--- + +## Common Reusable Schemas + +These schemas already exist in the spec. Always use `$ref` instead of redefining: + +| Schema | Use for | +|---|---| +| `error` | All error responses | +| `cursor_pages` | Pagination metadata in list responses | +| `reference` | Simple `{type, id}` reference to another object | +| `intercom_version` | The `Intercom-Version` header enum | +| `admin` | Admin/teammate objects | +| `contact` | Contact (user/lead) objects | +| `tag` | Tag with applied_at/applied_by | +| `tag_basic` | Tag without applied_at/applied_by | +| `tag_list` | List of tags | + +--- + +## Top-Level Spec Sections + +These sections appear at the bottom of each spec file (after `components/schemas`). Only modify when adding entirely new API resources. + +### Top-Level Tags (add for new resources) + +Located after `security:` at the end of the file. Tags define resource groups in the API docs sidebar. + +```yaml +tags: +- name: Admins + description: Everything about your Admins +- name: Articles + description: Everything about your Articles +# ... alphabetically ordered ... +- name: Your New Resource + description: Everything about your New Resource +``` + +Some tags have extended descriptions or `externalDocs`: +```yaml +- name: Conversations + description: Everything about your Conversations + externalDocs: + description: What is a conversation? + url: https://www.intercom.com/help/en/articles/4323904-what-is-a-conversation +``` + +Some tags use admonitions in descriptions: +```yaml +- name: Custom Object Instances + description: | + Everything about your Custom Object instances. + {% admonition type="warning" name="Permission Requirements" %} + From now on, to access this endpoint, you need additional permissions. + {% /admonition %} +``` + +### Servers (do NOT modify) + +```yaml +servers: +- url: https://api.intercom.io + description: The production API server +- url: https://api.eu.intercom.io + description: The european API server +- url: https://api.au.intercom.io + description: The australian API server +``` + +### Security (do NOT modify) + +```yaml +security: +- bearerAuth: [] +``` + +### Reusable Responses (`components/responses`) + +The spec defines reusable error responses. Most endpoints inline their errors, but these are available: + +```yaml +components: + responses: + Unauthorized: + description: Unauthorized + content: + application/json: + examples: + Unauthorized: + value: + type: error.list + request_id: 12a938a3-314e-4939-b773-5cd45738bd21 + errors: + - code: unauthorized + message: Access Token Invalid + schema: + "$ref": "#/components/schemas/error" +``` + +--- + +## Style Rules + +1. **Indentation**: 2 spaces throughout +2. **Strings**: Quote strings that could be ambiguous YAML (`'200'`, `'123'`, `'true'`) +3. **Descriptions**: Use `|` for multi-line descriptions, inline for single-line +4. **Examples**: Use realistic but fake data (not real customer data) +5. **Request IDs**: Use UUID format for `request_id` in error examples +6. **Timestamps**: Integer UNIX timestamps (e.g., `1734537437`), not ISO strings +7. **IDs**: String-quoted integers (e.g., `'123'`) for most resource IDs +8. **Refs**: Use `"$ref"` (quoted, with dollar sign) for JSON references +9. **Enum values**: Always include an `example` that matches one of the enum values +10. **operationId**: Must be unique across the entire spec +11. **Top-level tags**: Alphabetical order, required for new resources diff --git a/.claude/skills/generate-openapi-from-pr/ruby-to-openapi-mapping.md b/.claude/skills/generate-openapi-from-pr/ruby-to-openapi-mapping.md new file mode 100644 index 0000000..6f6252d --- /dev/null +++ b/.claude/skills/generate-openapi-from-pr/ruby-to-openapi-mapping.md @@ -0,0 +1,480 @@ +# Ruby-to-OpenAPI Mapping Reference + +This guide maps common Ruby API patterns in the intercom monolith to their corresponding OpenAPI YAML output. + +## Routes → Paths + +### Standard RESTful Resources + +```ruby +# Ruby route +resources :tags, only: [:index, :show, :create, :destroy] +``` + +Maps to: +```yaml +# OpenAPI paths +"/tags": + get: # index + operationId: listTags + post: # create + operationId: createTag +"/tags/{id}": + get: # show + operationId: findTag + delete: # destroy + operationId: deleteTag +``` + +### Member Routes (actions on a specific resource) + +```ruby +# Ruby route +resources :admins, only: [:index, :show] do + member do + put :away + end +end +``` + +Maps to: +```yaml +"/admins": + get: + operationId: listAdmins +"/admins/{id}": + get: + operationId: retrieveAdmin +"/admins/{id}/away": + put: + operationId: setAwayAdmin +``` + +### Collection Routes (actions on the resource collection) + +```ruby +# Ruby route +resources :conversations do + collection do + post :search + end +end +``` + +Maps to: +```yaml +"/conversations/search": + post: + operationId: searchConversations +``` + +### Nested Resources + +```ruby +# Ruby route +resources :contacts do + resources :tags, only: [:index, :create, :destroy] +end +``` + +Maps to: +```yaml +"/contacts/{contact_id}/tags": + get: + operationId: listTagsForAContact + post: + operationId: attachTagToContact +"/contacts/{contact_id}/tags/{id}": + delete: + operationId: detachTagFromContact +``` + +## Controller Actions → Operations + +### Action Name Conventions + +| Rails Action | HTTP Method | operationId Pattern | Example | +|---|---|---|---| +| `index` | GET | `list{Resources}` | `listTags` | +| `show` | GET | `retrieve{Resource}` or `find{Resource}` or `Show{Resource}` | `findTag` | +| `create` | POST | `create{Resource}` or `Create{Resource}` | `createTag` | +| `update` | PUT | `update{Resource}` or `Update{Resource}` | `UpdateContact` | +| `destroy` | DELETE | `delete{Resource}` | `deleteTag` | +| `search` | POST | `search{Resources}` | `searchConversations` | +| custom | varies | descriptive camelCase | `setAwayAdmin`, `convertVisitor` | + +Note: operationId naming is inconsistent in the existing spec (some camelCase, some PascalCase). Match the style of the resource group you're adding to. + +### Request Parameters + +```ruby +# Controller code +def update + tag = Tags::Update.run!( + params.slice(:name, :id).permit(:name, :id), + { app: current_app, admin: current_admin } + ) +end +``` + +Maps to request body: +```yaml +requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: The name of the tag. + example: "Updated tag name" + id: + type: string + description: The unique identifier of the tag. + example: "123" + examples: + successful: + summary: Successful update + value: + name: "Updated tag name" + id: "123" +``` + +### Path Parameters + +```ruby +# Controller with path param +def show + tag = Tag.find(params[:id]) +end +``` + +Maps to: +```yaml +parameters: +- name: id + in: path + description: The unique identifier of the tag. + example: '123' + required: true + schema: + type: string +``` + +### Query Parameters + +```ruby +# Controller with query params +def index + per_page = params[:per_page] || 50 + page = params[:page] || 1 +end +``` + +Maps to: +```yaml +parameters: +- name: per_page + in: query + description: Number of results per page. + schema: + type: integer + default: 50 +- name: page + in: query + description: Page number. + schema: + type: integer + default: 1 +``` + +## Presenters/Models → Schemas + +### Simple Presenter + +```ruby +# Ruby presenter +class TagResponse < ::Api::Versioning::Presenter + serialized_attributes do + attribute :type + attribute :id, stringify: true + attribute :name + end + + def type + "tag" + end +end +``` + +Maps to schema: +```yaml +components: + schemas: + tag: + title: Tag + type: object + x-tags: + - Tags + description: A tag allows you to label your contacts, companies, and conversations. + properties: + type: + type: string + description: value is "tag" + example: tag + id: + type: string + description: The id of the tag. + example: '123' + name: + type: string + description: The name of the tag. + example: Test tag +``` + +### Attribute Type Mapping + +| Ruby/Presenter Pattern | OpenAPI Type | +|---|---| +| `attribute :name` (string field) | `type: string` | +| `attribute :id, stringify: true` | `type: string` (even though integer in DB) | +| `attribute :count` (integer field) | `type: integer` | +| `attribute :enabled` (boolean field) | `type: boolean` | +| `attribute :created_at` (timestamp) | `type: integer` + `format: date-time` | +| `attribute :items` (array) | `type: array` + `items:` | +| `attribute :metadata` (hash/object) | `type: object` | +| Field can be nil | Add `nullable: true` | + +### Nested Object References + +```ruby +# Presenter with nested object +class ConversationResponse + serialized_attributes do + attribute :assignee # returns an Admin object + attribute :tags # returns a TagList object + end +end +``` + +Maps to: +```yaml +properties: + assignee: + "$ref": "#/components/schemas/admin" + tags: + "$ref": "#/components/schemas/tag_list" +``` + +For nullable nested objects, use `allOf`: +```yaml +properties: + applied_by: + type: object + nullable: true + description: The admin who applied the tag. + allOf: + - "$ref": "#/components/schemas/reference" +``` + +### List/Collection Schema + +```ruby +# List presenter +class TagListResponse + def type; "list"; end + attribute :data # array of TagResponse +end +``` + +Maps to: +```yaml +tag_list: + title: Tags + type: object + description: A list of tags. + properties: + type: + type: string + enum: + - list + example: list + data: + type: array + description: A list of tags + items: + "$ref": "#/components/schemas/tag" +``` + +## Version Changes → What's New + +### Field Addition (via transformation) + +```ruby +# Version change that removes field for old versions +class AddTagsToConversationPart < ::Api::Versioning::Change + define_transformation ::Api::V3::Models::VersionedConversationPartResponse do |target_model:, data:| + data.except(:tags) + end +end +``` + +This means: **`tags` is a NEW field** added in this version. It's removed from the response for older API versions. + +In OpenAPI: Add the `tags` property to the response schema, and include it in response examples. + +### Field Modification (via transformation) + +```ruby +# Version change that modifies field format +class ChangeTimestampFormat < ::Api::Versioning::Change + define_transformation ::Api::V3::Models::SomeResponse do |target_model:, data:| + data.merge(created_at: data[:created_at].to_s) + end +end +``` + +This means: the field format changed between versions. Document the new format. + +### New Endpoint (via version gating) + +```ruby +# Controller with version check +class TicketsController < OauthAuthenticatedController + requires_version_change ::Api::Versioning::Changes::AddTicketsApi + + def show + # ... + end +end +``` + +This means: the entire endpoint only exists in versions that include `AddTicketsApi`. Add the endpoint to the Unstable spec (or whichever version includes the change). + +### Version Registration Location + +```ruby +# In app/lib/api/versioning/service.rb +UnstableVersion.new(changes: [ + Changes::AddNewFeature, # → add to descriptions/0/ +]) + +Version.new(id: "2.15", changes: [ + Changes::SomeOtherChange, # → add to descriptions/2.15/ +]) +``` + +## Response Rendering → Response Schema + +### Simple Response + +```ruby +render_json Api::V3::Models::TagResponse.from_model(model: tag, api_version: current_api_version) +``` + +Maps to: +```yaml +responses: + '200': + description: Successful response + content: + application/json: + schema: + "$ref": "#/components/schemas/tag" +``` + +### Error Responses + +```ruby +raise Api::V3::Errors::ApiCodedError.new( + status: 404, + type: Api::V3::Errors::Code::NOT_FOUND, + message: "Resource Not Found" +) +``` + +Maps to: +```yaml +'404': + description: Resource not found + content: + application/json: + examples: + Resource not found: + value: + type: error.list + request_id: a1b2c3d4-e5f6-7890-abcd-ef1234567890 + errors: + - code: not_found + message: Resource Not Found + schema: + "$ref": "#/components/schemas/error" +``` + +### Common Error Codes + +| Ruby Error Code | OpenAPI `code` value | HTTP Status | +|---|---|---| +| `NOT_FOUND` | `not_found` | 404 | +| `UNAUTHORIZED` | `unauthorized` | 401 | +| `PARAMETER_INVALID` | `parameter_invalid` | 400 | +| `PARAMETER_NOT_FOUND` | `parameter_not_found` | 400 | +| `INTERCOM_VERSION_INVALID` | `intercom_version_invalid` | 400 | +| `FORBIDDEN` | `forbidden` | 403 | +| `UNPROCESSABLE_ENTITY` | `unprocessable_entity` | 422 | + +## Multiple Request Body Shapes (oneOf) + +When a single endpoint accepts different request body formats (e.g., POST `/tags` can create a tag, tag companies, untag companies, or tag users), use `oneOf`: + +```ruby +# Controller accepts different params depending on the operation +def create + if params[:companies] + # tag/untag companies + elsif params[:users] + # tag users + else + # create/update tag + end +end +``` + +Maps to: +```yaml +requestBody: + content: + application/json: + schema: + oneOf: + - "$ref": "#/components/schemas/create_or_update_tag_request" + - "$ref": "#/components/schemas/tag_company_request" + - "$ref": "#/components/schemas/untag_company_request" + - "$ref": "#/components/schemas/tag_multiple_users_request" + examples: + create_tag: + summary: Create a tag + value: + name: test + tag_company: + summary: Tag a company + value: + name: test + companies: + - company_id: '123' +``` + +## Adding a Field to an Existing Schema + +The most common change type. Requires updates in TWO places: + +1. **Add the property to `components/schemas//properties`** +2. **Update ALL inline response examples** that return this schema + +To find all affected examples, search for `schemas/` in the spec: +```bash +grep -n 'schemas/ticket' descriptions/0/api.intercom.io.yaml +``` + +Then check each endpoint that references that schema `$ref` and add the new field value to its inline examples. diff --git a/.claude/skills/generate-openapi-from-pr/version-propagation.md b/.claude/skills/generate-openapi-from-pr/version-propagation.md new file mode 100644 index 0000000..7c20611 --- /dev/null +++ b/.claude/skills/generate-openapi-from-pr/version-propagation.md @@ -0,0 +1,137 @@ +# Version Propagation Guide + +How to decide which API version spec files to update and how to propagate changes across versions. + +## Version Directory Mapping + +| Version | Directory | Notes | +|---|---|---| +| Unstable | `descriptions/0/` | All new features land here first | +| 2.15 | `descriptions/2.15/` | Current latest stable | +| 2.14 | `descriptions/2.14/` | Stable SDK source (used by Fern) | +| 2.13 | `descriptions/2.13/` | | +| 2.12 | `descriptions/2.12/` | | +| 2.11 | `descriptions/2.11/` | | +| 2.10 | `descriptions/2.10/` | | +| 2.9 | `descriptions/2.9/` | | +| 2.8 | `descriptions/2.8/` | | +| 2.7 | `descriptions/2.7/` | | + +Each version file is independent — always check the actual `intercom_version` schema in the file for its `default` and `enum` values before editing. + +## Decision Tree: Which Versions to Update + +### 1. New Feature (most common) + +**Update: Unstable only** (`descriptions/0/api.intercom.io.yaml`) + +If the PR adds the version change to `UnstableVersion` in the versioning service, only update the Unstable spec. This is the case for ~95% of API changes. + +### 2. Bug Fix to Existing Documentation + +**Update: All affected versions** + +If the PR fixes a bug in how an existing field/endpoint is documented (wrong type, missing example, incorrect description), propagate the fix to all versions that have the endpoint. + +### 3. Feature Promoted to Stable Version + +**Update: Target version + all later versions + Unstable** + +If the PR adds a version change to a specific numbered version (e.g., `Version.new(id: "2.15")`), update that version and all later versions. Features in a numbered version are also in all subsequent versions. + +Example: Change added to 2.14 → update 2.14, 2.15, and Unstable. + +### 4. Backport to Older Versions + +**Update: All specified versions** + +Rare. If the PR explicitly mentions backporting, update all specified versions. Check the PR description and version change registration for which versions are affected. + +## Key Differences Between Version Files + +### intercom_version Enum + +Each version's spec has a different enum for `intercom_version`: + +**Unstable** includes all versions plus `Unstable`: +```yaml +intercom_version: + example: Unstable + default: Unstable + enum: + - '1.0' + - '1.1' + # ... all versions ... + - '2.14' + - Unstable +``` + +**Stable versions** include all versions up to and including themselves: +```yaml +# v2.15 +intercom_version: + example: '2.14' + default: '2.14' + enum: + - '1.0' + - '1.1' + # ... all versions up to ... + - '2.14' +``` + +Note: Stable specs set `default` to the previous stable version and do NOT include their own version number in the enum. For example, v2.15 has `default: '2.14'` and does not list `'2.15'`. Always check the actual file to confirm the pattern before editing — it may evolve with future releases. + +### info.version + +Each spec has a different `info.version`: +```yaml +# Unstable +info: + version: Unstable + +# v2.15 +info: + version: '2.15' +``` + +### Available Endpoints + +Unstable has endpoints that don't exist in stable versions. When adding an endpoint to Unstable, do NOT add it to stable versions unless the corresponding version change is registered in that version's change list. + +### Schema Differences + +Some schemas have additional fields in Unstable that don't exist in stable versions. When propagating a fix, be careful not to add Unstable-only fields to stable versions. + +## Propagation Checklist + +When updating multiple versions: + +1. **Start with Unstable** — make the change in `descriptions/0/api.intercom.io.yaml` +2. **Copy to each target version** — replicate the same change in each version file +3. **Verify consistency** — ensure the change makes sense in context of each version (don't add references to schemas that don't exist in older versions) +4. **Check intercom_version** — do NOT modify the `intercom_version` enum unless explicitly adding a new API version +5. **Run validation** — `fern check` validates the spec used by Fern (currently v2.14 + Unstable), but manually review other versions + +## Fern Validation Scope + +`fern check` only validates: +- `descriptions/2.14/api.intercom.io.yaml` (stable SDK source) +- `descriptions/0/api.intercom.io.yaml` (unstable SDK source) + +Changes to other version files (2.7-2.13, 2.15) are NOT validated by Fern. Be extra careful with YAML syntax in those files. + +## Fern Overrides + +When adding new endpoints to Unstable, you may need to add entries to `fern/unstable-openapi-overrides.yml` for SDK method naming: + +```yaml +paths: + '/your_new_endpoint': + get: + x-fern-sdk-group-name: + - yourResource + x-fern-sdk-method-name: list + x-fern-request-name: ListYourResourceRequest +``` + +Check existing entries in the overrides file for the pattern. Not all endpoints need overrides — Fern can often infer good names from the spec. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d7f7ddf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,133 @@ +# Intercom OpenAPI Specifications + +Source of truth for Intercom's public REST API [OpenAPI 3.0.1](https://www.openapis.org/) specs. Changes here drive SDK generation (TypeScript, Java, Python, PHP), Postman collections, and are manually synced to [developer-docs](https://github.com/intercom/developer-docs). + +Owner: `team-data-foundations` (see `REPO_OWNER`) + +## Structure + +``` +descriptions/ + 2.7/ ... 2.15/ # Stable versioned API specs (one YAML each) + 0/ # Unstable version (version "0" internally) +fern/ + generators.yml # SDK generation config (TS, Java, Python, PHP) + openapi-overrides.yml # Fern overrides for stable spec (v2.14) + unstable-openapi-overrides.yml # Fern overrides for Unstable + fern.config.json # Fern org config +scripts/ + run-sync.js # Entry point for spec upload (CI only) + upload-api-specification.js # Upload logic for ReadMe API + postman/ # Postman collection generation +postman/ # Postman collection outputs +``` + +## Key Files + +- **API specs**: `descriptions//api.intercom.io.yaml` — one self-contained OpenAPI 3.0.1 YAML per version (~23K lines for v2.15) +- **SDK config**: `fern/generators.yml` — defines 4 SDK groups: `ts-sdk`, `java-sdk`, `python-sdk`, `php-sdk` +- **Fern overrides**: `fern/openapi-overrides.yml` — patches applied on top of the spec for SDK generation + +## Development + +```bash +npm install +``` + +No dev server, no test suite, no linting commands. Validation is done via Fern: + +```bash +npm install -g fern-api +fern check # Validate spec +fern generate --preview --group ts-sdk # Preview SDK generation +``` + +## CI/CD (GitHub Actions) + +| Workflow | Trigger | What it does | +|---|---|---| +| `fern_check.yml` | PR + push to main | Validates spec with `fern check` | +| `preview_sdks.yml` | PR (changes in `fern/`) | Preview-generates all 4 SDKs and compiles them | +| `ts-sdk.yml` | Manual dispatch | Releases TypeScript SDK to npm | +| `java-sdk.yml` | Manual dispatch | Releases Java SDK to Maven | +| `python-sdk.yml` | Manual dispatch | Releases Python SDK to PyPI | +| `php-sdk.yml` | Manual dispatch | Releases PHP SDK to Packagist | +| `generate_postman_collection.yml` | Push to main | Deploys updated Postman collections | + +## Editing API Specs + +### Adding/Modifying Fields + +1. Edit `descriptions//api.intercom.io.yaml` +2. Add property definition with `type`, `description`, `example`, and `nullable` if applicable +3. Update inline response examples in the same file (examples are inline, schemas use `$ref`) +4. Apply change to ALL affected versions (specs are independent copies, not inherited) + +### OpenAPI Conventions + +```yaml +# Every property needs an example +field_name: + type: string + description: The field description. + example: 'some-value' + nullable: true # Explicit when field can be null + +# Response pattern: schema via $ref, examples inline +responses: + '200': + content: + application/json: + examples: + Successful response: + value: + type: resource + id: '123' + schema: + "$ref": "#/components/schemas/resource_schema" +``` + +### Cross-Version Changes + +When a change applies to multiple API versions, you must edit each version's YAML independently. There is no inheritance between versions — each `descriptions//api.intercom.io.yaml` is a standalone file. + +### Version Numbering + +- Stable versions: `2.7`, `2.8`, ..., `2.15` (current latest) +- Unstable: stored as `descriptions/0/` (mapped from "Unstable" in upload scripts) +- Every endpoint requires an `Intercom-Version` header parameter + +## SDK Generation (Fern) + +SDKs are generated from the spec using [Fern](https://buildwithfern.com/). The `fern/generators.yml` configures: +- **Stable SDK source**: `descriptions/2.14/api.intercom.io.yaml` (note: v2.14, not v2.15) +- **Unstable SDK source**: `descriptions/0/api.intercom.io.yaml` (namespace: `unstable`) + +### DANGER: Never run `fern generate` without `--preview` locally + +Running `fern generate --group ` (without `--preview`) auto-submits PRs to the SDK GitHub repos. This is CI-only. + +```bash +# SAFE — local preview only +fern generate --preview --group ts-sdk + +# DANGEROUS — opens PRs on SDK repos! +fern generate --group ts-sdk +``` + +## PR Workflow + +- PRs trigger `fern check` validation and SDK preview builds (if `fern/` changed) +- PR template asks for: what changed + which API versions are affected +- Merges to main auto-deploy Postman collections +- SDK releases are manual (workflow_dispatch) + +## Skills + +- **generate-openapi-from-pr** — Takes an intercom monolith PR and generates OpenAPI spec changes. Provide a PR URL/number from `intercom/intercom` and the skill analyzes the diff (controllers, presenters, version changes, routes) to produce the corresponding YAML updates in the target spec file(s). See `.claude/skills/generate-openapi-from-pr/SKILL.md`. This is the primary workflow for this repo — always use it when a user provides an intercom PR URL or asks to update the spec from a PR. + +## Downstream Consumers + +Changes in this repo must be manually synced to: +- [intercom/developer-docs](https://github.com/intercom/developer-docs) — the spec YAMLs are copied into `docs/references/@/rest-api/` +- SDK repos receive changes via Fern-generated PRs (automated on release)