Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

API reference

Remove internal endpoints from public API docs

Publish a public API reference from the spec your code generates, with an overlay that strips internal operations on every build.

By 7 min read

By the end of this guide, your public API reference is built from the spec your code generates, with every internal operation stripped out on every build by a separate overlay file. The spec itself stays untouched, so your generator can keep overwriting it, and a one-line check proves nothing internal reached the published site.

It builds on Generate API docs from an OpenAPI spec, which sets up a reference for the Acme Messages API at /api. If you already have an OpenAPI reference in Blume, you can follow along with your own spec.

The spec you can't edit

Acme's openapi.yaml is generated from the API's code on every release. Along with the public operations, it includes one the support team uses to resend a failed message. The generator marks it with an x-internal flag:

openapi: 3.1.0
info:
  title: Acme Messages API
  version: 1.0.0
servers:
  - url: https://api.acme.example/v1
tags:
  - name: Messages
  - name: Templates
  - name: Internal
    description: Operations the support team uses.
paths:
  /messages:
    post:
      operationId: sendMessage
      tags: [Messages]
      summary: Send a message
      # ...
  /messages/{id}:
    get:
      operationId: getMessage
      tags: [Messages]
      summary: Get a message
      description: >-
        Returns a message and its delivery status. Replay a failed
        message with POST /internal/messages/replay.
      # ...
  /templates:
    get:
      operationId: listTemplates
      tags: [Templates]
      summary: List templates
      # ...
  /internal/messages/replay:
    post:
      operationId: replayMessage
      tags: [Internal]
      summary: Replay a failed message
      x-internal: true
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ReplayRequest"
      responses:
        "202":
          description: Replay queued.
components:
  schemas:
    ReplayRequest:
      type: object
      properties:
        messageId:
          type: string

Blume doesn't treat x-internal specially. Left alone, the replay operation publishes like any other: a page at /api/internal/replay-message, a section on the overview, a search result, a Markdown copy, and an entry in llms.txt. Editing the spec by hand doesn't help, because the next release overwrites it.

What an overlay is

An OpenAPI Overlay is a separate YAML or JSON file that lists changes to make to a spec. Each change is an action with a target, a JSONPath expression that selects nodes in the spec, and one of three operations:

  • remove: true deletes every node the target selects.
  • update merges a value into every node it selects. Objects merge key by key, arrays gain the new items, and strings, numbers, and booleans are replaced.
  • copy merges in a node from elsewhere in the spec. It's new in Overlay 1.1.

Blume supports Overlay 1.0 and 1.1. It applies each overlay to the spec as you wrote it, before upgrading the spec to OpenAPI 3.1, so your targets follow the version your spec is in. The spec file on disk never changes.

Write the overlay

Create overlays/public.yaml beside your spec, with one action that removes every internal operation and one that fixes a description:

overlay: 1.1.0
info:
  title: Public Acme Messages docs
  version: 1.0.0
actions:
  - target: $.paths.*[?@['x-internal'] == true]
    description: Remove every operation marked internal.
    remove: true
  - target: $.paths['/messages/{id}'].get
    description: Stop pointing readers at the replay endpoint.
    update:
      description: Returns a message and its delivery status.

Read the first target from left to right:

  • $.paths is the spec's paths object.
  • .* selects every path item in it, like /internal/messages/replay.
  • [?@['x-internal'] == true] keeps the children of each path item whose x-internal is true. Those children are the operations, so the action removes the post under the replay path and nothing else.

The key uses bracket notation because x-internal contains a hyphen. The second action rewrites one description: the generated text for getMessage sends public readers to an endpoint they can no longer see, and since a description is a string, update replaces it.

Apply it

List the overlay beside the spec in your reference's config:

import { defineConfig } from "blume";
import { openapi } from "blume/reference";

export default defineConfig({
  reference: [
    openapi({
      spec: "./openapi.yaml",
      route: "/api",
      overlays: ["./overlays/public.yaml"],
    }),
  ],
});

Each overlay is a local path, resolved from your project root like the spec, or an http(s) URL. With more than one, Blume applies them in the order you list them, and each sees the result of the one before. If you publish several specs through sources, give each source its own overlays instead.

Run npx blume dev and open /api. The Internal section is gone from the overview, since a tag with no operations left gets no section, and /api/internal/replay-message is a 404. The Get a message page shows the new description.

Check what you published

Everything Blume builds from the spec sees the overlaid version: the operation pages, the sidebar, search, the Markdown copies, llms.txt and llms-full.txt, the JSON documents under /api/docs/, and the MCP server if it's on. Blume never serves your spec file itself. The /openapi.json Blume publishes by default describes its own JSON docs API, not your API.

You don't have to check each surface by hand. Build the site and search the output for anything internal:

npx blume build
grep -rli "/internal/" dist/

An empty result means no page, Markdown copy, search index, or text file mentions an internal path. This works because every internal operation here shares the /internal/ prefix, and so does the page route Blume would have given it. Search for whatever your internal operations have in common. On a server build, search dist/client/, which is the part your host serves.

To keep it that way, run the same check in CI after every build, so a leak fails the build instead of shipping:

npx blume build
if grep -rqi "/internal/" dist/; then
  echo "An internal endpoint reached the public docs." >&2
  exit 1
fi

Catch the next internal endpoint

A month later, the generator adds an operation for purging a template. It carries the same flag:

  /internal/templates/{id}:
    delete:
      operationId: purgeTemplate
      tags: [Internal]
      summary: Purge a template
      x-internal: true

Rebuild, and it's gone from the docs without a change to the overlay. That's the point of targeting the flag rather than the path. A target like $.paths['/internal/messages/replay'].post removes exactly one operation, and the new one would have published.

You might want a rule like "anything under /internal/" instead. JSONPath can't express that: its filters test the values inside a node, not the names of the keys that hold them, so no target can match a path by its URL. Agree on a marker inside the operation with whoever owns the generator. A flag like x-internal works, and so does a tag:

  - target: $.paths.*[?@.tags[?@ == 'Internal']]
    description: Remove every operation tagged Internal.
    remove: true

Clean up what's left behind

An overlay removes exactly what its targets select and nothing more. After the first action runs, these are still in the spec:

  • The path items themselves, now empty: /internal/messages/replay: {}.
  • The Internal tag and its description in the top-level tags list.
  • Schemas only the internal operations used, like ReplayRequest.
  • Prose in public operations that mentions an internal one, which the second action already fixed for getMessage.

Blume's own reference shows none of the first three: a path with no operations gets no page, a tag with no operations gets no section, and a schema only renders where an operation uses it. They're still in the document Blume builds from, though, and a Scalar embed puts that whole document in the page. Remove them too:

overlay: 1.1.0
info:
  title: Public Acme Messages docs
  version: 1.0.0
actions:
  - target: $.paths.*[?@['x-internal'] == true]
    description: Remove every operation marked internal.
    remove: true
  - target: $.paths[?length(@) == 0]
    description: Remove paths left with no operations.
    remove: true
  - target: $.tags[?@.name == 'Internal']
    description: Remove the Internal tag.
    remove: true
  - target: $.components.schemas.ReplayRequest
    description: Remove the schema only the replay operation used.
    remove: true
  - target: $.paths['/messages/{id}'].get
    description: Stop pointing readers at the replay endpoint.
    update:
      description: Returns a message and its delivery status.

Order matters here: the empty-path action only finds paths the first action emptied. A path item that also declares shared parameters isn't empty after its operations go, so target it by name. Schemas have no flag to filter on, so list each one an internal operation adds.

Keep the full spec private

An overlay changes what the docs show, not who can read the spec or call the API.

  • Keep the spec out of public/. Blume serves every file there as it is, at the site root, so public/openapi.yaml would publish the full spec at /openapi.yaml with every internal operation in it. A public/openapi.json also takes over the /openapi.json route from Blume's docs API description. Keep the spec at the project root or in a folder like specs/.
  • A spec at a public URL is already public. If spec points at an https:// URL anyone can fetch, the overlay hides operations from the docs, but the spec still lists them at that URL.
  • Docs aren't access control. Removing an operation from the docs doesn't stop anyone from calling it. Protect internal endpoints in the API itself, with authentication or network rules.

Troubleshooting

The internal operation still shows

A target that matches nothing changes nothing, silently, so the overlay applied but skipped the operation. Check three things in the spec: that x-internal sits on the operation and not on the path item above it, that it's the boolean true and not the string "true", and that the key is spelled the way your target spells it.

Targets that work on one spec miss on another

Overlays apply before Blume upgrades a spec to OpenAPI 3.1, so a target has to follow the spec as written. A Swagger 2.0 spec keeps its schemas under definitions, not components.schemas.

The whole reference disappeared

If an overlay can't be applied, Blume skips the reference rather than publish it without the overlay, since the overlay is often what hides the internal operations. blume build fails with BLUME_OPENAPI_UNAVAILABLE, and blume dev shows the same message as a warning. It names the overlay file and the action that failed. Common causes:

  • overlay must be an Overlay Specification version, 1.0.x or 1.1.x: the file is missing its version line, or uses another version.
  • An overlay also needs info with a title and version, and at least one action.
  • can't merge an object into a primitive value: an update targets a string, like a description, with an object. Target the node that holds the string instead, as the second action does.

The config rejects overlays

overlays sits beside spec. With sources, Blume stops with "overlays belongs to the spec shorthand; with sources, give each source its own overlays." Move the list into each source.

Next step

Write your first overlay

Add an overlay beside your spec and list it in the reference's config.

Read the overlays docs

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init