Skip to content

API Documentation

BrunoRosendo edited this page Aug 30, 2023 · 1 revision

API documentation is generated through the use of the Spring REST Docs API specification Integration (aka restdocs-api-spec), a Spring Rest Docs extension that builds an OpenAPI specification or a Postman collection from its description, included in the controller tests. To see examples of how to document the API, hop to one of the controller tests.

Contents

Generating the API specification

With IntelliJ

Run the generateDocs gradle task to generate the OpenAPI specification or the Postman collection.

With the command line

Run the following command in your shell:

./gradlew generateDocs

Results

Find the OpenAPI specification and Postman collection under docs/ after running the task.

How to document the API

As mentioned above, the API documentation is generated through the use of descriptions embedded in the controller tests. To make documentation easier and more consistent, some helper classes and methods have been created to wrap the underlying restdocs-api-spec and Spring Rest Docs.

These classes ensure that the documentation is consistent and structured and that it is generated only once for each payload. They also make documenting array variations of the same payload a lot easier.

If you have any doubts, feel free to check the controller tests for examples.

Adding new documentation tags

Tags are used to group the API endpoints in the generated specification. To add a new tag, add a mapping to the tag-descriptions.yaml file in the src/test/kotlin/pt/up/fe/ni/website/backend/utils/documentation folder. The mapping should be in the form of <tag name>: <tag description>, where the tag name is the name of the tag and the tag description is a short description of the tag.

Events: Endpoints and operations for event management.

Then update the Tag enum in the same folder to include the new tag. Note that the fullName property of the enum should match the tag name in the tag-descriptions.yaml file.

enum class Tag(override val fullName: String) : ITag {
    AUTH("Authentication"),
    ACCOUNT("Accounts"),
    EVENT("Events"),
    GENERATION("Generations"),
    POST("Posts"),
    PROJECT("Projects")
}

Documenting JSON payloads

JSON payloads are documented using the PayloadSchema helper class. This class generates a payload resprentation, associating the documented fields representing the payload and a schema name, ensuring that the schema is only generated once.

PayloadSchema objects are generally used as properties of ModelDocumentation's subclass objects, but can also be required for some methods.

The ModelDocumentation helper class is used to link a payload to a documentation tag, serving as the context for the documentation of a payload.

Documenting a single field

JSON fields are documented using the DocumentedJSONField helper class. This class keeps track of field properties, dynamically manipulating them as required by the documentation context.

These properties are:

Property Description Default value
path The name of the field.
description The description of the field. null
type The type of the field. null
optional Whether the field is optional. false
ignored Whether the field should be ignored. false
attributes A map of attributes to be added to the field. emptyMap()
isInRequest Whether the field is in the request payload. true
isInResponse Whether the field is in the response payload. true

Example:

 DocumentedJSONField("id", "Account ID", JsonFieldType.NUMBER, isInRequest = false)

The DocumentedJSONField class provides the getFieldDescriptor method to build a FieldDescriptor object from the defined properties, used by the underlying documentation framework.

The class also introduces a method extending MutableList, MutableList<DocumentedJSONField>.addFieldsBeneathPath, wich adds a list of fields as children of the field with the given path. It takes the following parameters:

Parameter Description Default value
path The path of the field to add the children to.
documentedJSONFields The list of fields to add as children.
optional Whether the fields are optional. false
ignored Whether the fields should be ignored. false
addRequest Whether the fields should be added to the request payload. false
addResponse Whether the fields should be added to the response payload. false

Note that the addRequest and addResponse parameters default to false, so you should explicitly set them to true if you want the fields to be added to the request or response payload.

Documenting a payload

To directly document a payload, use the PayloadSchema class with a schemaName and a list of DocumentedJSONField objects.

Example:

val passwordChangePayload = PayloadSchema(
    "password-change", 
    mutableListOf(
        DocumentedJSONField(
            "oldPassword", 
            "Current account password", 
            JsonFieldType.STRING
        ),
        DocumentedJSONField(
            "newPassword", 
            "New account password", 
            JsonFieldType.STRING
        )
    )
)

Direct documentation of payloads is useful for some of the documentation methods, but it is only necessary to do so if the payload is used as the request of a different payload, wrapped in the ModelDocumentation class.

The PayloadDocumentation class provides the following methods:

  • [Request|Response]().schema() - Returns the payload schema for the request or response.
  • [Request|Response]().getSchemaFieldDescriptors() - Returns the field descriptors for the request or response, building them from the DocumentedJSONField objects.
  • getPayloadArraySchema() - Returns a payload schema for an array of the payload.

Documenting a payload model

The ModelDocumentation class is used to document a payload model, associating it with a tag and a name. Models are supposed to inherit from this class and to add the fields they need to document.

Example:

class PayloadProject : ModelDocumentation(
    Tag.PROJECT.name.lowercase(),
    Tag.PROJECT,
    mutableListOf(
        DocumentedJSONField("title", "Project title", JsonFieldType.STRING),
        DocumentedJSONField("description", "Project description", JsonFieldType.STRING),
        DocumentedJSONField("isArchived", "If the project is no longer maintained", JsonFieldType.BOOLEAN),
        DocumentedJSONField(
            "technologies",
            "Array of technologies used in the project",
            JsonFieldType.ARRAY,
            optional = true
        ),
        DocumentedJSONField("technologies.*", "Technology", JsonFieldType.STRING, optional = true),
        DocumentedJSONField(
            "slug",
            "Short and friendly textual event identifier",
            JsonFieldType.STRING,
            optional = true
        ),
        DocumentedJSONField("id", "Project ID", JsonFieldType.NUMBER, isInRequest = false),
        DocumentedJSONField(
            "teamMembers",
            "Array of members associated with the project",
            JsonFieldType.ARRAY,
            isInRequest = false
        ),
        DocumentedJSONField(
            "teamMembersIds",
            "Array with IDs of members associated with the project",
            JsonFieldType.ARRAY,
            optional = true,
            isInResponse = false
        ),
        DocumentedJSONField(
            "teamMembersIds.*",
            "Account ID",
            JsonFieldType.NUMBER,
            optional = true,
            isInResponse = false
        )
    ).addFieldsBeneathPath(
        "teamMembers[]",
        PayloadAccount().payload.documentedJSONFields,
        addResponse = true,
        optional = true
    )
)

This class provides a getModelDocumentationArray method that generates a ModelDocumentation object containing an array of the payload, with the same tag.

Documenting an endpoint

To document an endpoint, set up the right ModelDocumentation subclass object and use the andDocument MockMVC extension method and its variants using the MockMVC result actions.

andDocument and variants parameters are:

Parameter Description Default value
documentation The ModelDocumentation subclass object to use.
summary The summary of the endpoint. null
description The description of the endpoint. null
requestHeaders A list of HeaderDescriptorWithType objects to document. emptyList()
responseHeaders A list of HeaderDescriptorWithType objects to document. emptyList()
urlParameters A list of ParameterDescriptorWithType objects to document. emptyList()
documentRequestPayload Whether the request payload should be documented. false
hasRequestPayload Whether the request has a payload. This associates the correct schema with the request, but doesn't document it. Intended to be used with "fail" tests false

andDocument method variants:

  • andDocument
  • andDocumentErrorResponse
  • andDocumentEmptyObjectResponse
  • andDocumentCustomRequestSchema
  • andDocumentCustomRequestSchemaErrorResponse
  • andDocumentCustomRequestSchemaEmptyObjectResponse

The "custom" variants allow to document a custom request schema, instead of the one associated with the ModelDocumentation object. They use an extra parameter:

Parameter Description Default value
requestSchema The PayloadSchema object to use.

Example with andDocument helper

// ...

val documentation: ModelDocumentation = PayloadProject()

// ...
@Test
fun `should return all projects`() {
    mockMvc.perform(get("/projects"))
        .andExpectAll(
            status().isOk,
            content().contentType(MediaType.APPLICATION_JSON),
            content().json(objectMapper.writeValueAsString(testProjects))
                )
        .andDocument(
            documentation.getModelDocumentationArray(),
            "Get all the projects",
            "The operation returns an array of projects, allowinto easily retrieve all the created " +
                "projects. This is useful for example in thfrontend project page, " +
                "where projects are displayed."
        )
}

Note about the state of the documentation framework

We are building our documentation on top of the Spring REST Docs framework, which is a great tool for documenting REST APIs. However, it is not flexible and easy to use enough for our needs. restdocs-api-spec is added on top of Spring REST Docs making the documentation process even more limiting and difficult to use.

That's what justifies the creation of all the helper methods and classes in this project, to make the documentation process easier and more flexible, less taunting for new developers and to provide an overall better documetation experience. Bringing the documentation closer to the code and to do it in a Test Driven Development (TDD) approach are main goals of the endpoint documentation of this project, so the documentation is always up to date and accurate, and the documentation process is as easy as possible, saving time and trouble in the long run.

Don't think that tools (Spring REST Docs, restdocs-api-spec) are the problem, because they are quite powerful, the problem lies in the needed interfaces and boilerplate code to use them. Keep in mind that there is a lot more functionality in this tools that isn't exposed by the current state of our documentation framework and that if you find useful methods you think are worth the trouble you should get our attention to them

Learn more about Spring REST Docs and restdocs-api-spec

To learn about the availabe documentation classes and methods read the Documenting your API section of the Spring REST Docs documentation and the Usage with Spring Rest Docs section of the restdocs-api-spec documentation. Keep in mind that most of the examples assume the usage of Spring boot with Java, but the same concepts apply to Spring with Kotlin.

Documenting JSON payloads

JSON payloads are documented using arrays of FieldDescriptor objects.

These are used in the context of the resource.builder method with the requestFields and responseFields as parameters, accompanied by the respective com.epages.restdocs.apispec.Schema object, that names the payload schema.

Documenting a single field

These objects are created using the fieldWithPath method, which takes as parameter the path to the field as described in JSON Field Paths. Then the resulting object is modified using the available methods to add the desired documentation to the field. The field description is added setting the description property of the object (e.g. fieldWithPath("name").description("The name of the event"))

Example without helpers

// ...

val documentedFields = listOf<FieldDescriptor>(
    fieldWithPath("id").type(JsonFieldType.NUMBER).description("Event ID"),
    fieldWithPath("title").type(JsonFieldType.STRING).description("Event title"),
    fieldWithPath("description").type(JsonFieldType.STRING).description("Event description"),
    fieldWithPath("registerUrl").type(JsonFieldType.STRING).description("Link to the event registration").optional(),
    fieldWithPath("dateInterval.startDate").type(JsonFieldType.STRING).description("Event beginning date"),
    fieldWithPath("dateInterval.endDate").type(JsonFieldType.STRING).description("Event finishing date").optional(),
    fieldWithPath("location").type(JsonFieldType.STRING).description("Location for the event").optional(),
    fieldWithPath("category").type(JsonFieldType.STRING).description("Event category").optional(),
    fieldWithPath("thumbnailPath").type(JsonFieldType.STRING).description("Path for the event thumbnail image"),
    fieldWithPath("associatedRoles[]").description("Array of Roles/Activity associations"),
    fieldWithPath("associatedRoles[].*.role").type(JsonFieldType.OBJECT).description("Roles associated with the activity").optional(),
    fieldWithPath("associatedRoles[].*.activity").type(JsonFieldType.OBJECT).description("An activity that aggregates members with different roles").optional(),
    fieldWithPath("associatedRoles[].*.permissions").type(JsonFieldType.OBJECT).description("Permissions of someone with a given role for this activity").optional(),
    fieldWithPath("associatedRoles[].*.id").type(JsonFieldType.NUMBER).description("Id of the role/activity association").optional(),
)

mockMvc.perform(get("/events/{id}", testEvent.id))
                .andDo(
                    document(
                        "events/{ClassName}/{methodName}",
                        snippets = arrayOf(
                            resource(
                                builder()
                                    .summary("Get events by ID")
                                    .description(
                                        """
                                        This endpoint allows the retrieval of a single event using its ID.
                                        It might be used to generate the specific event page.
                                        """.trimIndent(),
                                    )
                                    .pathParameters(parameterWithName("id").description("ID of the event to retrieve"))
                                    .responseSchema("event-response")
                                    .responseFields(documentedFields)
                                    .tag("Events")
                                    .build(),
                            ),
                        ),
                    ),
                )
// ...