Skip to content

feat: OpenAPI 3 decoration for Service plugins via annotations #612

Description

@mkjsix

Summary

Add a declarative mechanism to annotate RESTHeart Service plugins with OpenAPI 3 metadata, and expose an auto-generated aggregated specification at a dedicated endpoint.

Currently RESTHeart has no built-in support for OpenAPI documentation. Plugin developers must maintain external spec files that can easily go out of sync with the actual implementation. The proposed mechanism is non-invasive: plugins without annotations continue to work (a fallback spec is auto-generated from @RegisterPlugin metadata), while plugins that opt in get precise, co-located documentation.

Motivation

  • API consumers (frontend teams, third-party integrators) need a machine-readable contract to generate clients and write integration tests.
  • The existing @RegisterPlugin annotation already captures name, description, defaultURI, uriMatchPolicy, and secure — a natural foundation to extend.
  • OpenAPI 3 is the de-facto standard for REST API documentation; tools like Swagger UI, Redoc, and openapi-generator all consume it natively.
  • A RESTHeart-native solution avoids the sprawl of hand-maintained YAML files that quickly diverge from actual plugin behaviour.

Proposed design

New annotations (in commons)

// Primary annotation — placed on the Service class alongside @RegisterPlugin
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface OpenApiOperation {
    String summary()      default "";
    String description()  default "";
    String[] tags()       default {};
    String operationId()  default "";   // defaults to @RegisterPlugin.name()
    String[] methods()    default {"GET", "POST", "PUT", "PATCH", "DELETE"};
    boolean deprecated()  default false;

    OpenApiParam[]      parameters()  default {};
    OpenApiRequestBody  requestBody() default @OpenApiRequestBody(required = false);
    OpenApiResponse[]   responses()   default {};
}

@Retention(RetentionPolicy.RUNTIME)
@Target({})   // nested only
public @interface OpenApiParam {
    String name();
    String in();            // "query" | "path" | "header" | "cookie"
    String description()    default "";
    boolean required()      default false;
    String schema()         default "string";   // OpenAPI primitive type
    String example()        default "";
}

@Retention(RetentionPolicy.RUNTIME)
@Target({})
public @interface OpenApiRequestBody {
    boolean required()   default true;
    String mediaType()   default "application/json";
    String schema()      default "";   // JSON Schema inline or "$ref"
    String description() default "";
}

@Retention(RetentionPolicy.RUNTIME)
@Target({})
public @interface OpenApiResponse {
    int statusCode();
    String description() default "";
    String mediaType()   default "application/json";
    String schema()      default "";   // JSON Schema inline or "$ref"
    String example()     default "";
}

Example usage on a plugin

@OpenApiOperation(
    summary     = "Ping the server",
    description = "Returns a pong message, optionally echoing a custom query parameter.",
    tags        = {"health"},
    methods     = {"GET"},
    parameters  = {
        @OpenApiParam(
            name        = "msg",
            in          = "query",
            description = "Message to echo back",
            required    = false,
            schema      = "string",
            example     = "hello"
        )
    },
    responses = {
        @OpenApiResponse(statusCode = 200, description = "Pong",         mediaType = "text/plain"),
        @OpenApiResponse(statusCode = 401, description = "Unauthorized")
    }
)
@RegisterPlugin(
    name        = "ping",
    description = "Ping service",
    defaultURI  = "/ping",
    secure      = false,
    blocking    = false
)
public class PingService implements StringService { ... }

OpenApiService — aggregator plugin (in core)

A new built-in Service registered at /api/openapi.json (configurable):

  1. On @OnInit, iterates PluginsRegistry#getServices().
  2. For each registered Service, checks for @OpenApiOperation:
    • Present → builds a PathItem from the annotation.
    • Absent → generates a minimal fallback PathItem from @RegisterPlugin metadata and the inferred request/response type (see schema inference table below).
  3. Assembles an OpenAPI object and caches its JSON representation.
  4. Serves it on GET /api/openapi.json; returns 405 on other methods.
  5. Cache is invalidated on plugin hot-reload events.

An optional GET /api/openapi.yaml variant can be added in a follow-up.

SchemaInferrer — automatic schema from service type

Service type Inferred request/response schema
JsonService type: object (generic)
BsonService type: object (annotated as MongoDB doc)
StringService type: string
ByteArrayService type: string, format: binary
SseService no body; text/event-stream

OpenApiBuilder — assembly utility

public class OpenApiBuilder {
    public static JsonNode build(PluginsRegistry registry) {
        OpenAPI spec = new OpenAPI()
            .info(new Info().title("RESTHeart API").version(/* from manifest */));

        for (PluginRecord<Service<?,?>> record : registry.getServices()) {
            RegisterPlugin  rp = record.getInstance().getClass().getAnnotation(RegisterPlugin.class);
            OpenApiOperation oa = record.getInstance().getClass().getAnnotation(OpenApiOperation.class);

            PathItem path = (oa != null)
                ? buildFromAnnotation(oa, rp)
                : buildFallback(rp, record.getInstance());

            spec.path(rp.defaultURI(), path);
        }
        return new ObjectMapper().valueToTree(spec);
    }
}

New Maven dependency (in core/pom.xml)

<dependency>
    <groupId>io.swagger.core.v3</groupId>
    <artifactId>swagger-core</artifactId>
    <version>2.2.x</version>
</dependency>

Configuration

plugins-args:
  openapi:
    enabled: true
    path: /api/openapi.json          # default
    include-auto-generated: true     # include plugins without @OpenApiOperation
    info:
      title: "RESTHeart API"
      version: "1.0"
      contact-email: ""

Scope

  • New annotations @OpenApiOperation, @OpenApiParam, @OpenApiRequestBody, @OpenApiResponse added to commons.
  • OpenApiService, OpenApiBuilder, SchemaInferrer added to core.
  • Built-in plugins (ping, metrics, etc.) annotated as reference implementations.
  • Unit tests for OpenApiBuilder with annotated and non-annotated service stubs.
  • Integration test: GET /api/openapi.json → valid OpenAPI 3 document (validated against the OpenAPI 3.0 JSON Schema).

Tradeoffs considered

Approach Pro Con
Annotations (proposed) Declarative, co-located with code, zero boilerplate for simple cases Verbose for complex schemas; no IDE-level schema validation
OpenApiProvider interface Fully programmatic, any schema complexity Boilerplate; documentation scattered from registration metadata
External YAML/JSON files No compile-time dependency Easily goes out of sync; no discovery mechanism
Reflection on generics Zero annotations Cannot capture descriptions, examples, or response codes

The annotation approach is consistent with the existing @RegisterPlugin pattern already familiar to plugin developers.

Open questions

  • Should @OpenApiOperation support per-method granularity (separate @Get, @Post, … annotations) or is a single methods[] array sufficient for the initial implementation?
  • Should reusable schemas be definable globally (in components/schemas) or only inline per-operation?
  • Should the spec include MongoDB Collection API endpoints (proxied by the mongodb module) or only custom Service plugins?

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions