openapi-edit provides safe structural edits to an
OpenAPI 3.x specification — the kind of
change where touching one place obliges you to touch several others, and forgetting
one leaves a document that no longer resolves.
Status: early. The scope below is settled and operations arrive one at a time, as each earns its place.
RenameSchema,RedirectSchemaandExtractSchemaare the first.
Renaming a schema is the canonical example. The rename itself is a single map
operation, but every $ref that pointed at the old name is now dangling — and those
$refs can be anywhere: nested inside another schema's properties, inside an
allOf branch, in a response's content, in a parameter, in a callback. Getting this
right means walking the entire document. Getting it wrong means a spec that looks
fine and fails to resolve.
That traversal is worth writing once, carefully, and reusing.
This module serves two kinds of caller:
- Directly, when you are writing code against your own specification and want to make a specific change safely, without reimplementing the bookkeeping.
- As a dependency, for tools like
openapi-compressandopenapi-flattenthat run an algorithm over a whole specification and need the same primitives underneath.
go get github.com/MarkRosemaker/openapi-editimport (
"github.com/MarkRosemaker/openapi"
edit "github.com/MarkRosemaker/openapi-edit"
)
// Renames the schema and rewrites every reference to it.
if err := edit.RenameSchema(doc, "GetV1PetByPetIDOkJSONResponse", "Pet"); err != nil {
log.Fatal(err)
}The schema keeps its position among the components, so a rename produces a one-line change rather than reordering the section.
Renaming a schema to its current name does nothing and reports no error. Otherwise the rename fails, changing nothing at all, in three cases:
| Error | When |
|---|---|
ErrSchemaNotFound |
components.schemas has no schema under the old name |
ErrSchemaExists |
the new name is already taken by another schema |
ErrInvalidSchemaName |
the new name is not a valid key under components |
The second is the interesting one. Renaming onto an existing schema would silently discard one of two different definitions and repoint every reference at whichever survived — a change that looks successful and quietly alters the API.
The third matters more than validity alone suggests: a name containing / would
produce a reference that resolves somewhere else entirely, and one containing a
space would produce a reference that does not resolve at all. Component keys must
match ^[a-zA-Z0-9.\-_]+$.
ExtractSchema names a schema a document spells out inline wherever it is used. It moves the schemas a
function accepts into components.schemas under one name, and replaces each with a reference to it:
// Every inline array of RichText becomes a reference to RichTexts.
err := edit.ExtractSchema(doc, "RichTexts", func(s *openapi.Schema) bool {
return s.Type == openapi.TypeArray && s.Items != nil && s.Items.Ref != nil &&
s.Items.Ref.Identifier == "#/components/schemas/RichText"
})The first schema the function accepts becomes the component, so it should accept only schemas that are the
same. A description stays where it was, on the reference, since it says what the schema is used for there.
Schemas already in components.schemas are left alone.
It fails, changing nothing, with ErrSchemaExists or ErrInvalidSchemaName for the name, as a rename does,
and with ErrNoMatch if the function accepts no schema.
RenameSchema refuses to rename a schema onto a name that already exists
(ErrSchemaExists). RedirectSchema is for when that's exactly the point —
several near-duplicate schemas, typically ones an OpenAPI generator produced
one per endpoint that happen to describe the same thing, are being
consolidated onto one of them:
// Repoints every reference to "GetPetOkResponse" at "Pet", then removes
// "GetPetOkResponse" from components.schemas.
if err := edit.RedirectSchema(doc, "GetPetOkResponse", "Pet", ""); err != nil {
log.Fatal(err)
}What it actually does, precisely — this is a rewrite of references, not a combination of content:
- It finds every
$refin the document whose value is"#/components/schemas/GetPetOkResponse"(via the same [walkSchemas] traversalRenameSchemauses) and rewrites each one to"#/components/schemas/Pet", now resolving toPet. A discriminator'smappingvalue namingGetPetOkResponse, by name or by reference, is rewritten the same way, and so it is byRenameSchema. A discriminator that selectedGetPetOkResponseby its name alone, with nomappingentry, gains one (GetPetOkResponse: Pet), so a payload naming it still selects the schema it meant. Both functions do this. - A
oneOforanyOfthat listed bothGetPetOkResponseandPetnow listsPettwice, so it keeps only the first of those plain references. Two alternatives of the same schema are no alternative at all: a value matching one matches the other, so aoneOfcould never hold for it. A reference with keywords of its own beside the$refis kept, and a union the redirect did not change is left alone. - It deletes the
"GetPetOkResponse"entry fromcomponents.schemas. - It does not look at, merge, or otherwise change the content of either
schema.
Pet's definition (its properties, its bounds, its wording) is whatever it already was, byte for byte;GetPetOkResponse's definition is simply gone, not folded intoPet's.
If GetPetOkResponse carried bounds or wording worth keeping, pass it as
description instead of an empty string: it becomes the description
beside the $ref of every reference this repoints, replacing whatever
description that reference already had. That's the one piece of
GetPetOkResponse this function can carry forward — everything else about
its definition is discarded the moment step 3 above runs, so this is the
last chance to keep any of it on the sites that used it.
Redirecting a schema onto itself does nothing and reports no error.
Otherwise it fails, changing nothing at all, if either name is not in
components.schemas (ErrSchemaNotFound).
Each call walks the whole document, so a caller consolidating hundreds of
schemas should hand them over together. RenameSchemas and RedirectSchemas
take a map from old name to new and walk the document a fixed number of times
however many names the map holds:
if err := edit.RedirectSchemas(doc, map[string]string{
"GetPetOkResponse": "Pet",
"ListPetsOkItem": "Pet",
"GetOwnerOkResponse": "Owner",
}); err != nil {
log.Fatal(err)
}The result is the same as calling the single-name function for each entry, and
a failure again changes nothing. RenameSchemas renames all its schemas
together, so two can swap names; two taking the same new name is
ErrSchemaExists. RedirectSchemas refuses to redirect onto a schema it is
also redirecting away (ErrSchemaNotFound), since that schema would not be
there afterwards.
This is deliberately not the same operation as combining two schemas into
one wider shape (adding one's properties, enum values, etc. to the other) —
that's openapi-merge's job, and it works on two schema values directly
rather than on a document and its references. The two are meant to compose
at the call site rather than one wrapping the other: a caller deduplicating
a specification decides, using whatever means it likes (openapi-merge
included), which of two schemas should survive and what its content should
be, then calls RedirectSchema to point every reference at the survivor and
drop the one that lost. See Scope below.
CountReferences counts the references to each component schema, wherever in
the document they occur.
DescribeReferences gives every reference to the named schemas a description
beside the $ref, unless the reference has one of its own. A description says
what a schema is used for in one place, so before redirecting schemas that
describe the same shape onto one, describing the references to each keeps what
each meant where it was used:
if err := edit.DescribeReferences(doc, map[string]string{
"BotWorkspaceName": "The name of the bot's workspace.",
}); err != nil {
log.Fatal(err)
}It fails, changing nothing, if a name is not in components.schemas
(ErrSchemaNotFound).
An edit can leave components that nothing refers to anymore, such as the parts
of a schema that has been rewritten. RemoveUnreferenced removes those of the
names it is given that nothing refers to, by a $ref or in a discriminator's
mapping, and returns them:
removed := edit.RemoveUnreferenced(doc, "PageAllOf0", "PageAllOf1")It repeats until each name that remains is referred to, since removing one can leave another without a reference. A component not among the names stays, referred to or not, since a specification may define one only to document it.
Operations belong here when they satisfy two conditions: they mutate a document, and doing them correctly requires knowledge of the document beyond the node being changed.
In scope
- ✅ Renaming a component and rewriting every reference to it (
RenameSchema,RenameSchemas) - ✅ Repointing every reference to a duplicate component onto the one that
survives, and removing the duplicate (
RedirectSchema,RedirectSchemas) - ✅ Moving inline definitions into
components, replacing each with a reference (ExtractSchema) - ✅ Counting the references to each component (
CountReferences), and describing them where they are used (DescribeReferences) - ✅ Removing the components an edit left without a reference
(
RemoveUnreferenced)
Out of scope
- Deciding whether two things should be merged — that is
openapi-compare - Combining two independently inferred schemas into one wider schema that
covers what both described — that is
openapi-merge.RedirectSchemaabove is a different, narrower operation: it never reads or changes either schema's own definition, only the references that pointed at the one being discarded. - Whole-document policies such as flattening or deduplication — those are their own modules, and they are expected to use this one
- Anything universal enough to belong on the types themselves — that goes into
openapiinstead, so that users who only want to parse and validate a spec aren't made to carry it
| Module | Purpose |
|---|---|
| openapi | Parse, validate, and write OpenAPI 3.x specifications |
| openapi-compare | Compare specification objects — exact equality and shape equivalence |
| openapi-edit (this module) | Safe structural edits, such as renaming a schema and rewriting every $ref to it |
| openapi-flatten | Promote inline definitions into named components entries |
| openapi-compress | Deduplicate and merge equivalent component schemas |
| openapi-merge | Merge schemas that were inferred independently from different samples |
| openapi-enrich | Infer specification content from observed HTTP traffic |
| openapi-codegen | Generate Go types, clients, and servers from a specification |
- Go Reference: API documentation.
Contributions are welcome — please open an issue or a pull request on GitHub.
This project is licensed under the Apache 2.0 License.
