Swagger, or OpenAPI, isn’t just for documenting what your API does; it’s for defining how your API behaves over time, and the key to that is understanding hypermedia relationships. The most surprising truth is that your API can be a self-discoverable, evolving graph, not just a static list of endpoints.
Imagine you’re building a simple e-commerce API. You have products, orders, and customers. A typical API might expose these as separate endpoints:
/products
/products/{id}
/orders
/orders/{id}
/customers
/customers/{id}
This is fine for simple use cases, but it’s brittle. If you rename /customers to /users, every client breaks. More importantly, how does a client find an order associated with a specific product? They’d have to guess or hardcode a relationship.
Hypermedia, specifically using the links object in OpenAPI, allows you to define these relationships explicitly and dynamically. It turns your API into a navigable document.
Let’s look at a product resource:
paths:
/products/{productId}:
get:
summary: Get product details
parameters:
- name: productId
in: path
required: true
schema:
type: string
responses:
'200':
description: Product details
content:
application/json:
schema:
type: object
properties:
id:
type: string
example: "prod-123"
name:
type: string
example: "Wireless Mouse"
price:
type: number
example: 29.99
# Here's the magic: defining relationships
links:
type: object
properties:
self:
type: object
description: Link to this product resource
properties:
href:
type: string
example: "/products/prod-123"
method:
type: string
enum: [GET, PUT, PATCH, DELETE]
example: "GET"
reviews:
type: object
description: Link to reviews for this product
properties:
href:
type: string
example: "/products/prod-123/reviews"
method:
type: string
enum: [GET]
example: "GET"
addToCart:
type: object
description: Action to add this product to a cart
properties:
href:
type: string
example: "/cart/items"
method:
type: string
enum: [POST]
example: "POST"
# Optional: templated links for dynamic values
templated:
type: boolean
example: true
# Optional: schema for request body if method is POST/PUT/PATCH
schema:
$ref: '#/components/schemas/AddToCartRequest'
When a client requests /products/prod-123, they get back the product data and a links object. This object tells them:
self: How to get this specific product again.reviews: How to get all reviews for this product.addToCart: How to perform an action that uses this product.
The client doesn’t need to know that reviews are at /products/{productId}/reviews. It just follows the reviews link provided by the server.
This approach is powerful because it decouples the client from the API’s URL structure. If you decide to change the review endpoint to /product-reviews?productId=prod-123, only your server-side code needs to know. The client, which just followed the reviews link, remains unaffected.
Here’s what a real response might look like:
{
"id": "prod-123",
"name": "Wireless Mouse",
"price": 29.99,
"links": {
"self": {
"href": "/products/prod-123",
"method": "GET"
},
"reviews": {
"href": "/products/prod-123/reviews",
"method": "GET"
},
"addToCart": {
"href": "/cart/items",
"method": "POST",
"templated": true,
"schema": {
"type": "object",
"properties": {
"productId": {"type": "string"},
"quantity": {"type": "integer", "default": 1}
},
"required": ["productId"]
}
}
}
}
When the client wants to add this mouse to the cart, it looks at the addToCart link. It sees it’s a POST to /cart/items. If the link is templated, it might need to substitute values. In this case, the schema tells it what data to send in the request body. The client constructs the request:
POST /cart/items HTTP/1.1
Host: api.example.com
Content-Type: application/json
{
"productId": "prod-123",
"quantity": 1
}
The links object is not just for read operations. It’s crucial for defining state transitions and actions. For example, an order might have links to cancelOrder, payOrder, or trackShipment.
paths:
/orders/{orderId}:
get:
summary: Get order details
responses:
'200':
description: Order details
content:
application/json:
schema:
type: object
properties:
id:
type: string
example: "ord-xyz"
status:
type: string
example: "PENDING_PAYMENT"
total:
type: number
example: 150.00
links:
type: object
properties:
self:
type: object
href: "/orders/ord-xyz"
method: "GET"
cancel:
type: object
description: Cancel this order
# Link only appears if order can be cancelled
# This is where dynamic behavior comes in!
# If status is "SHIPPED", this link wouldn't exist.
href: "/orders/ord-xyz/cancel"
method: "POST"
payment:
type: object
description: Link to initiate payment
href: "/orders/ord-xyz/payment"
method: "POST"
The most subtle yet powerful aspect of hypermedia links is their ability to convey conditional availability. A cancel link might only appear on an order if its status is PENDING_PAYMENT. If the order is already SHIPPED, the cancel link simply won’t be present in the links object. This means clients don’t need to poll or check complex business logic; they just look at the links provided to know what actions are currently possible. This is the essence of RESTful APIs: discoverability through hypermedia.
The next step in mastering your API’s graph is understanding how to define and consume complex search and filtering capabilities using query parameters, often exposed via a dedicated search or collection link.