How to Mock HTTP Servers with Prism and OpenAPI specs

·

7 min read

Distributed systems often rely on various dependencies that communicate using HTTP REST APIs. However, there are instances where we don't have access to the codebase of these services, or they are third-party systems. In such cases, we need a way to simulate these services for testing purposes, whether manually or automatically. This is where mocking HTTP servers becomes invaluable.

Prism, in conjunction with OpenAPI specifications, offers an efficient way to set up a mock server, enabling manual and automated testing without needing access to the actual service or its codebase.

In this post, I aim to describe the OpenAPI Specification, introduce Prism, and explain how to set up end-to-end (E2E) tests using these tools and the Golang codebase.

Context

Consider a restaurant table reservation system. When a reservation is made, a notification is sent. These notifications are managed by a separate service that exposes a REST HTTP API.

In the best-case scenario, it is owned by a team within an organization and we can use a codebase or docker image to start the dependent service. However, there are many situations when the service is a third-party system and we do not have access to its internals.

In the next sections, we will show how to mimic interactions with NotificationService having only OpenAPI specification (if you do not have the OpenAPI, you can easily create it based on observable behavior).

About OpenAPI

OpenAPI is a powerful tool for documenting REST APIs in a standardized manner. It provides a common framework that can be used across different services and platforms, ensuring consistency in API documentation. By using OpenAPI, the structure of your API is clearly defined. This includes details about endpoints, request/response formats, and more. Here is an example of NotificationService HTTP API which exposes a single /emails POST endpoint:

# notifications-service.yml

openapi: 3.0.3
info:
  description: |
    Notifications Service REST API
  version: "v1"
  title: Notifications Service
paths:
  /emails:
    post:
      summary: Send email
      description: Send an email
      operationId: send_email
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EmailRequest"
      responses:
        "202":
          description: Successful operation. Email sending triggered.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EmailResponse"
              examples:
                success1:
                  summary: Email sent
                  value:
                    emailId: "email1"
                success2:
                  summary: Email sent
                  value:
                    emailId: "email2"
        "400":
          description: Invalid request body

components:
  schemas:
    EmailRequest:
      type: object
      required:
        - address
        - title
        - body
      properties:
        address:
          type: string
          format: email
        title:
          type: string
        body:
          type: string
    EmailResponse:
      type: object
      properties:
        emailId:
          type: string

We defined the /emails path and two components: incoming request body represented by EmailRequest object and EmailResponse which defines the endpoint response.

There are many tools available that can generate code, client libraries, and even API documentation websites directly from an OpenAPI specification. We can also use a specification to generate a mock HTTP server and use it for manual and automated tests.

What is prism

Prism is a valuable tool for mocking HTTP servers using OpenAPI specifications. It can serve as a mock server that replicates the behavior of your API. This allows developers to work on the client side of an application without needing the backend to be fully implemented. You don't need to write any additional code to mock your API. Provide a properly formatted OpenAPI YAML specification, and Prism will handle the rest. Prism validates incoming requests against the OpenAPI specification, ensuring that the requests conform to the defined schema. Prism also generates dynamic responses based on the OpenAPI spec, allowing you to simulate various scenarios such as different status codes, error responses, etc.

The best way to show Prism would be a simple example. Using the Docker Compose file could be the most straightforward method of constructing the Prism mock server for the NotificationService.

version: "3"
services:
  notification-service:
    ports:
      - "8010:8010"
    image: "stoplight/prism:4.10.5"
    command:
      [
        "mock",
        "-h",
        "0.0.0.0",
        "-p",
        "8010",
        "/tmp/file.yaml"
      ]
    volumes:
      - "./notification-service.yml:/tmp/file.yaml"
    platform: linux/amd64

We used stoplight/prism:4:10.5 image to build a Prism container. The notification-service.yml specification file served as a template for creating the mock server, which would operate on a port 8010.

$ docker-compose --project-name=x up
[+] Running 2/1
 ⠿ Network x_default                    Created                                                                                                                                       0.1s
 ⠿ Container x-notifications-service-1  Created                                                                                                                                       0.0s
Attaching to x-notifications-service-1
x-notifications-service-1  | [6:51:35 PM] › [CLI] …  awaiting  Starting Prism…
x-notifications-service-1  | [6:51:43 PM] › [CLI] ℹ  info      POST       http://0.0.0.0:8010/emails
x-notifications-service-1  | [6:51:43 PM] › [CLI] ▶  start     Prism is listening on http://0.0.0.0:8010

Now we can hit /emails endpoint and get a response:

$ curl -v -X POST http://localhost:8010/emails -H "Content-Type: application/json" -d '{"title": "test", "body": "context", "address": "t@x.pl"}' | jq --color-output
< HTTP/1.1 202 Accepted
{
  "emailId": "email1"
}

Prism can be configured to return specific responses (defined in a spec) based on request parameters and headers making it flexible for different testing needs. To get the desired response we need to pass an additional header Preferred with a name of the example from OpenAPI spec (e.g. example=success2).

$ curl -X POST http://localhost:8010/emails -H "Content-Type: application/json" -H "Prefer: example=success2;" -d '{"title": "test", "body": "context", "address": "t@x.pl"}' | jq --color-output
< HTTP/1.1 202 Accepted
{
  "emailId": "email2"
}

If we made a request without the required parameters (e.g. without email address), we will get 400 Bad Request response.

curl -v -X POST http://localhost:8010/emails -H "Content-Type: application/json" -H "Prefer: example=success2;" -d '{"title": "test", "body": "context"}' | jq --color-output
< HTTP/1.1 400 Bad Request
< sl-violations: [{"location":["request","body"],"severity":"Error","code":"required","message":"must have required property 'address'"}]

In the next section, we will demonstrate how to use Prism in the setup of end-to-end tests.

Manual tests

Let's build a simplified version of a system that can trigger a reservation and send a notification. This system will consist of:

  1. A main application that handles the reservation logic.

  2. A notification client that sends notifications.

We'll start by defining the reservation logic, which includes triggering the notification.

type ReservationService struct {
    notificationClient *NotificationClient
}

func NewReservationService(notificationClient *NotificationClient) *ReservationService {
    return &ReservationService{
        notificationClient: notificationClient,
    }
}

func (rs *ReservationService) Reserve(email string) error {
    // here should be logic representing reservation

    err := rs.notificationClient.SendEmail(EmailMessage{
        Address: email,
        Title:   "Reservation",
        Body:    "You have successfully reserved a table",
    })
    if err != nil {
        return fmt.Errorf("failed to send email: %w", err)
    }
    fmt.Println(fmt.Sprintf("Reservation made for %s", email))
    return err
}

type EmailMessage struct {
    Address string `json:"address"`
    Title   string `json:"title"`
    Body    string `json:"body"`
}

Here's a concise and focused version of the ReservationService that communicates with a NotificationService using a simple NotificationClient. The core logic of the reservation system is omitted for simplicity. The NotificationClient to send a notification makes an HTTP POST request to /emails endpoint.

type NotificationClient struct {
    baseURL    string
    httpClient *http.Client
}

func NewNotificationClient(baseURL string) *NotificationClient {
    return &NotificationClient{
        baseURL:    baseURL,
        httpClient: &http.Client{},
    }
}

func (c *NotificationClient) SendEmail(message EmailMessage) error {
    jsonData, err := json.Marshal(message)
    if err != nil {
        return fmt.Errorf("failed to marshal notification: %w", err)
    }
    req, err := http.NewRequest("POST", fmt.Sprintf("%s/emails", c.baseURL), bytes.NewBuffer(jsonData))
    if err != nil {
        return fmt.Errorf("failed to create request: %w", err)
    }
    req.Header.Set("Content-Type", "application/json")

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return fmt.Errorf("failed to send request: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusAccepted {
        return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
    }
    fmt.Println(fmt.Sprintf("Reservation Email sent to %s", message.Address))
    return nil
}

To get this working, we only need the baseURL of the NotificationService. We haven't started it yet, but we can still determine how our system will behave without the NotificationService running.

func main() {
    fmt.Println("Starting reservation service")
    notificationsBaseURL := "http://localhost:8010"
    notificationClient := NewNotificationClient(notificationsBaseURL)
    reservationService := NewReservationService(notificationClient)
    err := reservationService.Reserve("test@wp.pl")
    if err != nil {
        fmt.Println(fmt.Sprintf("Failed to make reservation: %v", err))
    }
    return
}

What happens when we run the above code?

$ go run main.go
Starting reservation service
Failed to make reservation: failed to send email: failed to send request: Post "http://localhost:9100/emails": go run main.go

As we expected, the NotificationService is not running and we got dial tcp 127.0.0.1:8010: connect: connection refused error.

Let start the NotificationService using Prism. In our Docker Compose file, we forwarded the port 8010 of the NotificationService, so it should be accessible from localhost.

$ docker-compose --project-name=x up
[+] Running 1/0
 ⠿ Container x-notifications-service-1  Created                                                                                                                                       0.0s
Attaching to x-notifications-service-1
x-notifications-service-1  | [7:17:05 PM] › [CLI] …  awaiting  Starting Prism…
x-notifications-service-1  | [7:17:14 PM] › [CLI] ℹ  info      POST       http://0.0.0.0:8010/emails
x-notifications-service-1  | [7:17:14 PM] › [CLI] ▶  start     Prism is listening on http://0.0.0.0:8010

Now, we can execute main.go again:

$ go run main.go
Starting reservation service
Reservation Email sent to test@wp.pl
Reservation made for test@wp.pl

It is working :) The next step could be to automate the above test using Testcontainers setup (I hope in the future post).

Conclusion

Using an OpenAPI specification with a Prism mock server provides an efficient way to simulate dependent services during development and testing. This approach allows developers to create realistic mock APIs that can be used to test various scenarios without the need for the actual services to be up and running.

The codebase related to this post can be found here.