The generation of OpenAPI documents is a multistep process.
ApiAnalyse: Analyse controllers and used classes using reflection to build aApimodelApiDescribe: Read descriptions from markdown files to insert into theApimodelOpenApiGenerator: Generate the OpenAPI document from theApimodel
During the 3rd step of generating the document Java endpoints can be found to
collide in terms of OpenAPI semantics. In that case the colliding endpoints
are merged by the ApiMerger.
The easiest way to generate the documents is to use the OpenApiTool.
This is a plain old Java program that starts from a main method.
The tool will use the .class files of the controllers as starting point.
Therefore, it is necessary to use the tool after DHIS2 has been compiled
successfully.
While it theoretically is possible to use the tool directly from command line this would require adding all dependent modules to the classpath.
The much easier way is to just run the tool in the IDE which will automatically add the maven dependencies.
To run, create a Java Application Run Configuration. The easiest is to click
the run arrow displayed on OpenApiTool class or it's main method.
This prints back the usage info:
Usage: [<options>] [<path or tag>...] <output-file-or-dir>
--group (flag)
generate multiple files where controllers are grouped by tag
Edit the created configuration and add options, filters and target dir or file.
| Program Arguments | Output |
|---|---|
openapi-target.json |
generates a single document for all controllers with the provided name |
--group /example/openapi |
generates multiple documents in the target dir, controllers are grouped into documents by their first @OpenApi.Tags tag |
/users openapi-users.json |
generates a single document with the provided name including any controller with the root path /users |
/users /userGroups openapi-users-and-groups.json |
generates a single document with the provided name including any controller with a root path either being /users or /userGroups |
user openapi-tagged-user.json |
generates a single document with the provided name including any controller tagged user on the controller class |
user system openapi-tagged-user-or-system.json |
generates a single document with the provided name including any controller tagged user or tagged system on the controller class |
A filter starting with a / is a path filter, otherwise it is a tag filter.
Path and tag filters can be combined, then a controller has to match both filters.
The generation of OpenAPI documents is also accessible on a running server.
The document is generated "on-the-fly" when it is requested.
Similar to the application tool content can be filtered by path and/or by tag.
For example,
- https://play.dhis2.org/dev/api/openapi/openapi.json generates a document including all controllers
- https://play.dhis2.org/dev/api/openapi/openapi.json?path=/users generates a document containing only controllers with root path
/users - https://play.dhis2.org/dev/api/openapi/openapi.json?tag=user generates a document containing only controller tagged
user
Again multiple filters can be used and path and tag filters can be combined.
The generated JSON documents can be turned into browsable documentation using tools like swagger or stoplight studio.
Be careful with MB size files. Some tools cannot handle them at all or freeze.
We use https://dhis2.stoplight.io/studio/dhis2:main to manage. Special authority is required.
To view the latest published documents goto https://dhis2.stoplight.io/docs/dhis2 .
The generated document originates from a reflection based analysis. This analysis makes use of Java language declarations as well as spring and jackson annotations.
However, anything not visible in type or method signatures cannot be inferred. Some framework conversions or conventions can cause a misrepresentation of the actual REST API.
To correct such errors or missing information the family of @OpenApi
annotations is used To adjust or correct the OpenAPI document without having to
change the function of the code.
In some occasions it might still be easier and more meaningful to instead express the code differently, so it becomes more clear both for the reader and the reflection analysis.
By default, any class annotated @Controller or @RestController is analysed
and (if not filtered) included in the generated document.
TLDR: Anything that should not be in the generated OpenAPI document can be
excluded by annotating the element with @OpenApi.Ignore.
To ignore...
- an entire controller class annotate the class with
@OpenApi.Ignore - a specific endpoint method annotate the method with
@OpenApi.Ignore - a specific parameter of an endpoint method annotate the parameter with
@OpenApi.Ignore - a property field or method of a schema annotated the field or getter method with
@OpenApi.Ignore
Ignoring always takes precedence over other adjustment annotations.
TLDR: Add @OpenApi.Tags with at least one tag to each controller class
All controller classes should be tagged to add them to a group and to further structure the generated document.
@OpenApi.Tags({"user", "query"})
class UserLookupController {}In context of OpenAPI specification tags do not have order semantics but tools such as stoplight give special semantics to tags based on their position.
We use the first tag to group documents when --group is used.
The second tag becomes the first for the endpoints within the document which is
used by stoplight to group the endpoints.
All tags present on a controller class are added to all endpoints in that class. Tags can also be applied directly to endpoint methods which adds further tags to that endpoint in the document. This has only informal purposes at this point.
TLDR: Use @OpenApi.Param (single) and @OpenApi.Params (multi) on endpoint methods.
Not all parameters become visible from the endpoint method signature.
In such a case the @OpenApi.Param and @OpenApi.Params annotations can be
used to add additional parameters.
To add a single parameter to a target endpoint method annotate it with
@OpenApi.Param providing the name and type of the parameter.
class Controller {
@OpenApi.Param(name = "fields", value = String[].class)
@GetMapping
List<Entry> getEntryList(Map<String, String> params) {
}
}Since annotations do not allow for generics use array types instead of collection types. For the resulting OpenAPI document this makes no difference.
To add multiple parameters to a target endpoint method annotate it with
@OpenApi.Params providing the type of the parameters class.
class Controller {
@OpenApi.Params(WebOptions.class)
@GetMapping
List<Entry> getEntryList(Map<String, String> params) {
}
}Usually such classes should then be annotated @OpenApi.Shared.
This will use globally shared parameter definitions in the resulting
OpenAPI document.
To adjust what members are made into parameters the fields or getters can be
annotated with either @OpenApi.Ignore or @OpenApi.Property.
TLDR: Use @OpenApi.Param on parameters to change their assumed type
In some situations parameters are inferred from the spring annotations but the
type used in the endpoint method signature is not what represents them best.
To substitute the type with a more specific one annotate the parameter with
@OpenApi.Param. This can be applied on top of spring's @PathVariable and
@RequestParam or on method parameters that have neither of these.
OpenApi.Param takes precedence over spring's annotations but when name is left
empty it falls back to name from spring annotation. Similar the required status
is determined considering both annotations when present.
class Controller {
@GetMapping( "/{uid}" )
Entry getEntry( @OpenApi.Param( UID.class ) @PathVariable( "uid" ) String pvUid ) {}
}For example, the more specific UID type overrides String for the uid
parameter while the name still falls back to "uid" from @PathVariable
because @OpenApi.Param did not specify another name.
TLDR: Use @OpenApi.Param with no (empty) name to set/override the request body type
To set or override the request body of an endpoint the @OpenApi.Param
annotation is used with no (empty) name property.
When present the request body from the signature is ignored.
TLDR: Use @OpenApi.Response to add responses for further HTTP status codes
The default response is inferred from the endpoint method signature and spring's
@ResponseStatus annotation. To add further responses add one or more
@OpenApi.Response annotations to the endpoint method.
class Controller {
@OpenApi.Response(ObjectListResponse.class)
@OpenApi.Response(status = FORBIDDEN, value = WebMessage.class)
@GetMapping
List<Entry> getEntryList(Map<String, String> params) {
}
}TLDR: Use @OpenApi.Response with no status to override the default response
When no status is specified the response overrides the type of the default
response. So, in the above example List<Entry> is effectively replaced by
ObjectListResponse.
TLDR: Use @OpenApi.Ignore and @OpenApi.Property on fields or getter methods
Properties are used for parameters objects as well as data objects like DHIS2's persistent and DTO objects as they occur in request and response.
By default, that analysis is looking for jackson's @JsonProperty annotation.
If no annotations are present all properties based on getters are added.
To adjust the described property selection use @OpenApi.Ignore to exclude a
property. This should be placed where jackson's @JsonProperty is present if
it is used.
To add a property that otherwise is not picked up annotate the field or getter
with @OpenApi.Property. This can also be used adjust the assumed type of
properties.
If both @JsonProperty and @OpenApi.Property are present @OpenApi.Property
takes precedence.
TLDR: Use @OpenApi.EntityType on class and method level to define the
substitution within the class/method context, use @OpenApi.EntityType in
other annotations to use the type from the actual endpoint context.
The analysis does not perform type parameter substitution during analysis. As a consequence the actual type of type parameters is lost or unknown. However, a much simpler type substitution was added instead.
Each controller can define one substitution type by annotating the class:
@OpenApi.EntityType( ActualType.class )
class Controller {}This default is inherited by each endpoint method in that class unless it is overridden by a method level annotation:
@OpenApi.EntityType( ActualType.class )
class Controller {
@EntityType( AnotherActualType.class )
@GetMapping
ResponseEntity<?> getObject() {}
}To use the defined type use EntityType.class in any of the OpenApi
annotations that specify a type of parameters or responses.
class Controller extends BaseController {
@OpenApi.Response( EntityType.class )
@GetMapping
ResponseEntity<?> getObject() {}
}At first glance this appears to substitute one specific type with another one.
However, when adding inheritance this is no longer the case.
The endpoint context defines @EntityType differently for different
controllers inheriting an endpoint from a common abstract base class.
For each of these the actual type used is different now.
This can also be applied to complex DTOs which have a field based on the context dependent type.
class ExampleResponse<T> {
@JsonProperty
Pager pager;
@OpenApi.Property( EntityType[].class )
@JsonProperty
List<T> entries;
}In this example List<T> is now substituted with different actual array
types based on what is the substitution type for the endpoint. For example,
User[], UserGroup[]... . Again, making use of the fact that array and
collection types have the same result in an OpenAPI document.
TLDR: Use path$ as property name to substitute it with root path (minus /)
In some responses the name of a property should be different based on that type
of objects is returned. For example a list of users is named users, a list of
data elements dataElements. This is usually solved by some jackson
serialisation mechanism that the analysis does not understand.
A simple mechanism was added for the specific use case in DHIS2.
The property name path$ is substituted to the root path of the current
controller context.
@RequestMapping("/users")
class UserController {}In this example a property named path$ in the source (either this is the
actual name of the field or method or the name given via annotation) is
replaced with users.
Similar to EntityType substitution for types the actual value now depends on
the controller context and can become different actual names for different
controllers using or inheriting the type with the special path$ property.