Spring Boot

Pull the document springdoc-openapi already serves at /v3/api-docs into your docs build, then shape the reference with swagger-core's @Extension annotations.

springdoc-openapi introspects your Spring MVC or WebFlux controllers and serves the document at /v3/api-docs. Everything Markline needs beyond that comes from swagger-core's @Extension / @ExtensionProperty annotations — with one sharp edge around value types that's worth reading before you start.

Emit the document

springdoc generates at runtime, so the build step is "start the app, take a snapshot." The official Maven plugin does exactly that during integration-test:

<plugin>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-maven-plugin</artifactId>
  <version>1.4</version>
  <executions>
    <execution>
      <phase>integration-test</phase>
      <goals><goal>generate</goal></goals>
    </execution>
  </executions>
  <configuration>
    <apiDocsUrl>http://localhost:8080/v3/api-docs</apiDocsUrl>
    <outputFileName>openapi.json</outputFileName>
    <outputDir>${project.basedir}/../docs/api</outputDir>
  </configuration>
</plugin>

Pair it with spring-boot-maven-plugin's start / stop goals in pre-integration-test / post-integration-test. On Gradle, the org.springdoc.openapi-gradle-plugin equivalent does the same.

If you'd rather not wire a plugin, the framework-agnostic version is two lines and works everywhere:

java -jar target/app.jar &
until curl -sf localhost:8080/v3/api-docs -o ../docs/api/openapi.json; do sleep 1; done
kill %1

Set the document metadata once — info.title names the client in generated code samples, info.version drives the version pill:

@OpenAPIDefinition(
    info = @Info(title = "Acme API", version = "1.4.2"),
    servers = @Server(url = "https://api.acme.com")
)
@SpringBootApplication
public class AcmeApplication { }

Tags become resources

@Tag on the controller is what Markline groups by, and slashes nest:

@Tag(name = "store/orders")
@RestController
@RequestMapping("/orders")
public class OrderController { }

That renders a Store parent with an Orders child, routed at /api-reference/store-orders. See Nested tags.

Declare tags at the document level to fix resource order and give each one a description — anything you omit sorts after the listed tags, alphabetically:

@OpenAPIDefinition(
    info = @Info(title = "Acme API", version = "1.4.2"),
    tags = {
        @Tag(name = "accounts", description = "Balances and statements."),
        @Tag(name = "payments"),
        @Tag(name = "store/orders")
    }
)

Order the sidebar

Here's the sharp edge. @ExtensionProperty.value() is a String, and parseValue() defaults to false — so the obvious spelling emits "x-nav-order": "1", a JSON string. Markline only honours x-nav-order when it's a number, so a string is silently ignored and nothing moves.

Set parseValue = true:

@Operation(
    summary = "Create an account",
    extensions = @Extension(properties = @ExtensionProperty(
        name = "x-nav-order", value = "1", parseValue = true
    ))
)
@PostMapping
public Account create(@RequestBody AccountCreate body) {}
Leave parseValue off and you get "x-nav-order": "1". It's valid OpenAPI, it lints clean, and it does nothing. This is the single most common way to wire this up wrong on the JVM.

Note the empty @Extension name: with no name, swagger-core promotes each property to a top-level extension key (prefixing x- if you left it off). Give @Extension a name and you get a nested object instead — which is what you want for x-events below, and not what you want here.

This is also the case that can't be solved by reordering source. Markline reads verbs in a fixed order, so GET /accounts always precedes POST /accounts however the controller is written. Number your operations 10, 20, 30 and you can insert one later without renumbering its neighbours.

Events

Give @Extension a name and its properties become a nested object — which is exactly the shape x-events wants. The payload is JSON, so it needs parseValue = true too:

@Operation(
    summary = "Create an account",
    extensions = @Extension(name = "events", properties = @ExtensionProperty(
        name = "account.created",
        value = """
            {
              "summary": "A new account was opened",
              "payload": { "$ref": "#/components/schemas/AccountCreatedEvent" },
              "guide": "/guides/webhooks#account-created"
            }
            """,
        parseValue = true
    ))
)
@PostMapping
public Account create(@RequestBody AccountCreate body) {}

@Extension(name = "events", …) produces the key x-events — swagger-core prepends x- for you. Placed on the operation, the event inherits the controller's @Tag, so POST /accounts gets a Triggers chip and the event an Emitted by back-link.

The $ref only resolves if AccountCreatedEvent reached components.schemas. If no endpoint returns it, add @Schema(implementation = AccountCreatedEvent.class) somewhere reachable, or inline the payload schema instead of using $ref.

Full behaviour in Events & webhooks.

Code samples

x-codeSamples is an array, which the nested-object form can't express. Use the empty-name form with a JSON array as the value:

@Operation(
    extensions = @Extension(properties = @ExtensionProperty(
        name = "x-codeSamples",
        value = """
            [{ "lang": "java", "label": "Java SDK",
               "source": "acme.accounts().create(AccountCreate.of(\\"[email protected]\\"));" }]
            """,
        parseValue = true
    ))
)
One custom sample replaces the entire generated rail for that operation — list every language you want shown.

If you don't publish an SDK, don't annotate anything: set "codeSamples": ["curl"] in markline.json and the invented acme.accounts.create(…) snippets disappear everywhere at once.

Gotchas

parseValue = true on every non-string value. Numbers, booleans, objects and arrays all arrive as strings otherwise. It's the first thing to check when an extension "doesn't work."

Blank values are dropped. swagger-core skips any @ExtensionProperty whose name or value is blank, so an empty string won't clear an inherited value.

Empty @Extension name vs named. No name → each property becomes its own top-level x- key. A name → one x-<name> key holding a map of the properties. Picking the wrong one is the second most common failure.

operationIds collide and get suffixed. springdoc derives them from the method name and appends _1, _2 on collision. Markline routes per-operation deep links and MDX overlays off operationId, so those suffixes leak into URLs and filenames — and shift when you add a method. Pin them with @Operation(operationId = "createAccount").

Only the first tag groups. A controller with two @Tags puts its operations on the first one; the second doesn't create a second placement.

Text blocks and quotes. A Java text block takes bare " happily, so the JSON keys and values above need no escaping. The exception is a quote that has to survive into the JSON string — write \\" so the runtime string holds \", which the JSON parser then reads as a quote. Write \" and the block collapses it to ", terminating the JSON string early.