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:
A main application that handles the reservation logic.
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.