1. Overview

Two popular approaches to designing REST APIs are often used: Swagger and HATEOAS. Both aim to make APIs more user-friendly and understandable but follow distinct paradigms.

In this tutorial, we’ll see the difference between Swagger and HATEOAS and some common use cases.

2. What Is Swagger?

Swagger is a set of open-source tools for building, documenting, and consuming REST APIs. It allows developers to describe the structure of their APIs using a JSON or YAML file based on the OpenAPI Specification (OAS).

Let’s look at Swagger’s key features.

2.1. Code Generation

With Swagger, we can automatically generate interactive API documentation, code, and client libraries. Swagger can also create server stubs and client SDKs in various programming languages, speeding up development.

It’s an API-first approach that defines a contract between the requirements and the people maintaining the application.

Developers can use tools like SwaggerHub to create boilerplate code for different programming languages by providing a Swagger specification file. For example, let’s look at a YAML template for a simple User endpoint:

openapi: 3.0.1
info:
  title: User API
  version: "1.0.0"
  description: API for managing users.

paths:
  /users:
    get:
      summary: Get all users
      security:
        - bearerAuth: []  # Specifies security for this endpoint
      responses:
        '200':
          description: A list of users.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/User'
        '401':
          description: Unauthorized - Authentication required
        '500':
          description: Server error

    post:
      summary: Create a new user
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewUser'
      responses:
        '201':
          description: User created successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          description: Invalid input
        '401':
          description: Unauthorized - Authentication required
        '500':
          description: Server error

  /users/{id}:
    get:
      summary: Get user by ID
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            example: 1
      responses:
        '200':
          description: User found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '401':
          description: Unauthorized - Authentication required
        '404':
          description: User not found
        '500':
          description: Server error

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT  # JWT specifies the type of token expected

  schemas:
    User:
      type: object
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          example: John Doe
        email:
          type: string
          example: [email protected]
        createdAt:
          type: string
          format: date-time
          example: "2023-01-01T12:00:00Z"

    NewUser:
      type: object
      properties:
        name:
          type: string
          example: John Doe
        email:
          type: string
          example: [email protected]
      required:
        - name
        - email

Let’s get an overview of the YAML file:

  • General Information (info): The API title, version, and a brief description are included.
  • Paths:
    • GET /users: Retrieves all users, returning a 200 response with an array of User objects.
    • POST /users: It creates a new user. It expects a request body with the NewUser schema and returns a 201 response with the created user object.
    • GET /users/{id}: Retrieves a specific user by ID. Includes a 404 response if the User isn’t found
  • Components:
    • User schema: Defines the structure of a user object, including fields like id, name, email, and createdAt.
    • NewUser schema: Used in the request body for creating a new user, requiring name and email fields.
    • SecuritySchemes: This section defines how the API handles security. In this case, we specify a bearerAuth scheme, which uses Bearer tokens, often JWTs (JSON Web Tokens), in API security contexts.

We can define almost everything about an API and automatically generate it for the most common languages, speeding up this part of the process,

2.2. API Documentation

We can also directly apply the Open API documentation tags in our project’s code. Either with automatic generation or manual tagging, let’s look at how the user endpoint could look in a Java Spring REST application:

@RestController
@RequestMapping("/api/users")
public class UserController {
    // fields and constructor
    @Operation(summary = "Get all users", description = "Retrieve a list of all users")
    @ApiResponses(value = {
      @ApiResponse(responseCode = "200", description = "List of users", 
        content = @Content(mediaType = "application/json", schema = @Schema(implementation = User.class))),
      @ApiResponse(responseCode = "500", description = "Internal server error") })
    @GetMapping
    public ResponseEntity<List<User>> getAllUsers() {
        return ResponseEntity.ok()
          .body(userRepository.getAllUsers());
    }

    @Operation(summary = "Create a new user", description = "Add a new user to the system")
    @ApiResponses(value = {
      @ApiResponse(responseCode = "201", description = "User created", 
        content = @Content(mediaType = "application/json", schema = @Schema(implementation = User.class))),
      @ApiResponse(responseCode = "400", description = "Invalid input") })
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<User> createUser(
      @RequestBody(description = "User data", required = true, 
        content = @Content(schema = @Schema(implementation = NewUser.class))) NewUser user) {
        return new ResponseEntity<>(userRepository.createUser(user), HttpStatus.CREATED);
    }

    @Operation(summary = "Get user by ID", description = "Retrieve a user by their unique ID")
    @ApiResponses(value = {
      @ApiResponse(responseCode = "200", description = "User found", 
        content = @Content(mediaType = "application/json", schema = @Schema(implementation = User.class))),
      @ApiResponse(responseCode = "404", description = "User not found") })
    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable Integer id) {
        return ResponseEntity.ok()
          .body(userRepository.getUserById(id));
    }
}

Let’s look at some of the most important annotations:

  • @Operation: It adds a summary and description for each API operation, helping to describe what the endpoint does and what it’s for.
  • @ApiResponse: It defines an individual response for an HTTP status code, including a description and the expected content type and schema.
  • @Content: Specifies a response or request body’s content type (e.g., application/json) and provides the schema for data serialization.
  • @Schema: Describes the data model for request and response bodies, associating classes (like User) with the JSON structure displayed in Swagger.

2.3. Interactive Console

The Swagger UI console is an interactive, web-based interface that dynamically generates documentation from OpenAPI specifications. It allows developers and API consumers to explore and test endpoints visually. The console displays API endpoints, request parameters, responses, and error codes organized in a user-friendly layout.

Each endpoint provides fields to input parameter values, headers, and request bodies, enabling users to make live requests directly from the console. This functionality helps developers understand API behavior, verify integrations, and troubleshoot issues without needing separate tools, making it an essential resource for API development and testing. For example, we can see a Swagger UI example for a pet store.

2.4. Benefit of an API First Approach

Why should we use a unique API contract or template for documentation?

A template ensures that all endpoints across the API follow a uniform structure. This consistency simplifies understanding and using the API, both for the internal development team and external consumers. For example, developers, QA engineers, and external stakeholders have a clear, shared understanding of the API’s capabilities and structure.

Furthermore, clients can experiment with the API directly within the documentation, making the API easier to adopt and integrate without needing extensive additional support. We can set up automated tests to ensure the API’s structure and responses meet specifications.

3. What Is HATEOAS?

HATEOAS (Hypermedia as the Engine of Application State) is a constraint of REST application architecture. It’s part of the broader REST paradigm and emphasizes that clients interact with a REST API entirely through hypermedia provided dynamically by the server. In HATEOAS, the server includes links within its responses, guiding the client on the next actions.

3.1. HATEOAS Example

Let’s look at a Spring HATEOAS application. First, we need to define our User as part of a specific representation model:

public class User extends RepresentationModel<User> {
    private Integer id;
    private String name;
    private String email;
    private LocalDateTime createdAt;

    // Constructors, Getters, and Setters
}

Now, let’s see an example of how we can implement it for the user endpoint:

@RestController
@RequestMapping("/api/hateoas/users")
public class UserHateoasController {
    // fields and constructor

    @GetMapping
    public CollectionModel<User> getAllUsers() {
        List<User> users = userService.getAllUsers();

        users.forEach(user -> {
            user.add(linkTo(methodOn(UserController.class).getUserById(user.getId())).withSelfRel());
        });

        return CollectionModel.of(users, linkTo(methodOn(UserController.class).getAllUsers())
          .withSelfRel());
    }

    @GetMapping("/{id}")
    public EntityModel<User> getUserById(@PathVariable Integer id) {
        User user = userService.getUserById(id);
        user.add(linkTo(methodOn(UserController.class).getUserById(id)).withSelfRel());
        user.add(linkTo(methodOn(UserController.class).getAllUsers()).withRel("all-users"));
        return EntityModel.of(user);
    }

    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<EntityModel<User>> createUser(@RequestBody NewUser user) {
        User createdUser = userService.createUser(user);
        createdUser.add(
          linkTo(methodOn(UserController.class).getUserById(createdUser.getId())).withSelfRel());
        return ResponseEntity.created(
          linkTo(methodOn(UserController.class).getUserById(createdUser.getId())).toUri())
            .body(EntityModel.of(createdUser));
    }
}

Let’s look at a sample response for the getAllUsers endpoint, where we can discover the User’s actions and related resources dynamically via the links:

[
    {
        "id": 1,
        "name": "John Doe",
        "email": "[email protected]",
        "createdAt": "2023-01-01T12:00:00",
        "_links": {
            "self": {
                "href": "http://localhost:8080/users/1"
            }
        }
    },
    {
        "id": 2,
        "name": "Jane Smith",
        "email": "[email protected]",
        "createdAt": "2023-02-01T12:00:00",
        "_links": {
            "self": {
                "href": "http://localhost:8080/users/2"
            }
        }
    }
]

3.2. Tests

To understand more in detail, let’s look at some integration tests for the controller.

Let’s start by getting all the users:

@Test
void whenAllUsersRequested_thenReturnAllUsersWithLinks() throws Exception {
    User user1 = new User(1, "John Doe", "[email protected]", LocalDateTime.now());
    User user2 = new User(2, "Jane Smith", "[email protected]", LocalDateTime.now());

    when(userService.getAllUsers()).thenReturn(List.of(user1, user2));

    mockMvc.perform(get("/api/hateoas/users").accept(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk())
      .andExpect(jsonPath("$._embedded.userList[0].id").value(1))
      .andExpect(jsonPath("$._embedded.userList[0].name").value("John Doe"))
      .andExpect(jsonPath("$._embedded.userList[0]._links.self.href").exists())
      .andExpect(jsonPath("$._embedded.userList[1].id").value(2))
      .andExpect(jsonPath("$._embedded.userList[1].name").value("Jane Smith"))
      .andExpect(jsonPath("$._links.self.href").exists());
}

In this case, we expect each User we retrieve to have a relative path by id.

Let’s also look at the endpoint to get the User by id:

@Test
void whenUserByIdRequested_thenReturnUserByIdWithLinks() throws Exception {
    User user = new User(1, "John Doe", "[email protected]", LocalDateTime.now());

    when(userService.getUserById(1)).thenReturn(user);

    mockMvc.perform(get("/api/hateoas/users/1").accept(MediaType.APPLICATION_JSON))
      .andExpect(status().isOk())
      .andExpect(jsonPath("$.id").value(1))
      .andExpect(jsonPath("$.name").value("John Doe"))
      .andExpect(jsonPath("$.email").value("[email protected]"))
      .andExpect(jsonPath("$._links.self.href").exists())
      .andExpect(jsonPath("$._links.all-users.href").exists());
}

We now expect all users by id reference to exist in the response.

Finally, after we create a new user, we also expect the new reference to be in the response:

@Test
void whenUserCreationRequested_thenReturnUserByIdWithLinks() throws Exception {
    User user = new User(1, "John Doe", "[email protected]", LocalDateTime.now());
    when(userService.createUser(any(NewUser.class))).thenReturn(user);

    mockMvc.perform(post("/api/hateoas/users").contentType(MediaType.APPLICATION_JSON)
        .content("{\"name\":\"John Doe\",\"email\":\"[email protected]\"}"))
      .andExpect(status().isCreated())
      .andExpect(jsonPath("$.id").value(1))
      .andExpect(jsonPath("$.name").value("John Doe"))
      .andExpect(jsonPath("$._links.self.href").exists());
}

3.3. Key Points

As we have seen, HATEOAS APIs include links in their responses, guiding the client’s actions. This reduces the need for the client to hard-code endpoint routes and enables a more flexible interaction with the API.

Likewise, it provides a way for the client to follow links provided by the server to navigate through various states or actions dynamically, enabling more adaptive workflows. Therefore, we can think of HATEOAS as the ultimate step in making our API explorable so the client can understand its behavior.

4. Key Differences Between Swagger and HATEOAS

Let’s illustrate the differences between Swagger and HATEOAS:

Aspect

Swagger

HATEOAS

API Documentation

Swagger provides detailed, human-readable API documentation with a UI, allowing consumers to understand available endpoints, request parameters, and responses in advance.

HATEOAS relies on hypermedia links returned by the server within responses, meaning that documentation is more implicit. Therefore, consumers discover actions dynamically through these links rather than a pre-generated UI.

Client-side Implementation

Clients are typically generated or written based on Swagger specifications. The API’s structure is known beforehand, and the client can make requests according to predefined paths.

HATEOAS clients interact with the API dynamically, discovering available actions through hypermedia links within responses. The client does not need to know the complete API structure in advance.

Flexibility

Swagger is more rigid, expecting predefined endpoints and a consistent API structure. This makes it harder to evolve the API without updating documentation or the spec.

HATEOAS offers more flexibility, allowing the API to evolve by changing the hypermedia-driven responses without breaking existing clients.

Consumer Ease

It is easy for consumers who rely on auto-generated documentation or tools that create client code directly from the API spec.

It is more complex for consumers since they need to interpret responses and follow hypermedia links to discover further actions.

API Evolution

Any change in the API structure requires updating the Swagger spec, regenerating client code, and distributing it to users.

HATEOAS allows easier changes as the client discovers the API through hypermedia, requiring fewer updates when the API evolves.

Versioning

Swagger typically requires explicit versioning and the maintenance of multiple versions of the API separately.

HATEOAS evolves without strict versioning because clients can dynamically follow the provided links.

HATEOAS focuses on dynamically guiding clients through API interactions using hypermedia links embedded in responses. At the same time, Swagger (or OpenAPI) provides static, human-readable, and machine-readable API documentation describing the API’s structure, endpoints, and operations.

5. Conclusion

In this article, we learned about Swagger and HATEOAS, with some application examples highlighting the main differences. We saw how to generate source code from a YAML template or use Swagger annotations to decorate our endpoints. For HATEOAS, we saw how to improve our model definition by adding valuable links to navigate all the resources related to an endpoint.

As always, the code is available over on GitHub.