Restbucks with Hydra

From Hydra Community Group

Introduction

In How to GET a Cup of Coffee, Jim Webber demonstrated back in 2008 how a restful service can support a workflow using hyperlinks within service responses.

The goal of this page is to show how this would work with Hydra, solely based on publicly available vocabs, avoiding custom vocabs.

Webber started by describing the initial POST which creates an order, assuming that the service and the consumer have already agreed on the format and semantics of the representations.

The basic idea is that clients which want to make an order should look for an "orders" resource and POST to it. Traditionally one would define a hyperlink with an extension (custom) rel "ex:orders" on an entry resource which allows to POST orders to it.

One problem with this is that each service defines its own extension rels so that a client for service A has no idea how to work with service B. We want to avoid that and establish a common interaction pattern which allows every hydra client to interact with any hydra service to make orders.

Entry Resource: The coffee shop

In Hydra we describe the entry resource and we are going to tell the client how to post an order.

First of all, we need an entry resource. Schema.org allows to say that someone or something offers products and services. In this case, there is even a specific class for a coffee shop, so our coffee shop can design a resource of type CafeOrCoffeeShop which makesOffer to sell a Latte Macchiato at 2.80 EUR. For the actual product type "Latte Macchiato" we used a subtype of schema:Product from productontology.org to make the product safely recognizable for a machine client.

 {
   "@context": {
     "@vocab": "http://schema.org/",
     "pto": "http://www.productontology.org/doc/"
   },
   "@type": "CafeOrCoffeeShop",
   "@id": "http://example.com/store",
   "name": "Kaffeehaus Hagen",
   "makesOffer": [
     {
       "@type": "Offer",
       "@id": "http://example.com/offers/1234",
       "itemOffered": {
         "@type": "pto:Latte_macchiato",
         "@id": "http://example.com/products/latte-1",
         "productID": "latte-1"
         "name": "Latte Macchiato",
       },
       "price": 2.8,
       "priceCurrency": "EUR"
     }
   ]
 }

Ordering a Latte Macchiato

How do we enable clients to order a Latte Macchiato? What we want to say is that our Latte Macchiato item has a related orders collection to which clients can POST. In schema.org there is no "orders" property, but the opposite exists: schema:orderedItem relates an Order to a Product.

Telling the Client Where to Order

We need a reverse "orderedItem" relationship which relates a product to an order, but that's easy: We add a hydra:collection property to our Latte Macchiato, with a hydra:Collection that manages the schema:orderedItem property so that the subject is a schema:Order, the predicate is schema:orderedItems and the collection members are the objects. I.e. in the manages block of the collection below we say that the collection items are orderedItems of an Order, just as we need it. That way we can avoid a private extension rel "ex:orders" and use the commonly understood property schema:orderedItem instead. The client can now look for a schema:orderedItem collection and find the URL http://example.com/orders for it.

 { 
   "@context": {
     ...
     "hydra": "http://www.w3.org/ns/hydra/core#",
     "hydra:property": {"@type": "@vocab"}
   },
   ...
   "name": "Latte Macchiato",
   "hydra:collection": [
     {   
       "@type": "hydra:Collection",
       "@id": "http://example.com/orders",
       "hydra:manages": {
           "hydra:property": "orderedItem",
           "hydra:subject": { "@type": "Order" }
       },
       "hydra:operation": {
         "hydra:method": "POST",
         "hydra:expects": {
           "hydra:supportedProperty": [
             {
               "@type": "PropertyValueSpecification",
               "hydra:property": "productID",
               "hydra:required": true,
               "defaultValue": "latte-1",
               "readOnlyValue": true
             }
           ]
         }
       }
     }
   ]
   ...
 }

The schema:orderedItem property is somewhat nested in the manages block inside the collections array, but a client looking for the "orderedItem" collection could build a lookup map from the manages blocks to ease finding related collections. The collection construct is not unlike a _links block where every link item represents a link to a remote collection which can be looked up by a rel.

It is worth noting that the subject in the manages block is not an order which can be identified - it has no @id attribute, because the order to which the product will belong does not exist yet. We can merely say that the subject will be of type schema:Order once it has been created, hence the subject has a @type but no @id.

Telling the Client How to Order

We not only tell the client about the URL but we also say that it can POST to that URI using hydra:operation and hydra:method. And finally, we describe the expected POST request using hydra:expects and hydra:supportedProperty. Our server expects the client to send an object which has a read-only "productID" property set to "latte-1". Note that the "readOnlyValue" and "defaultValue" attributes are not part of hydra, they are properties of schema:PropertyValueSpecification. We use properties both from hydra and schema.org to describe the supported property. See SHACL[1] for a possible alternative.

We have extended the @context a bit more. Of course, we had to add the hydra namespace information. Furthermore, by saying

"hydra:property": {"@type": "@vocab"}

we made it clear that the value of hydra:property should be interpreted as a name in the current vocab, so that "productID" is resolved to http://schema.org/productID by json-ld processors.

We say that the service tells the client how to order. But in the age of single page applications and mobile apps it should be clear that the user interface need not also follow the flow by switching pages after every interaction. The service is a data service, it does not define the UI.

Creating the Initial Order

Now we have told the client that it may POST to "http://example.com/orders" with the following request:

POST /example.com/orders HTTP/1.1
...
{
 "@context": "http://schema.org/",
 "productID": "latte-1" 
}

The response could be:

201 Created
Location: http://example.com/orders/1234

{
 "@context": {
   "@vocab": "http://schema.org/"
 },
 "@type": "Order",
 "@id": "http://example.com/orders/1234"
 "orderedItem": [
   {
   "@type": "pto:Latte_macchiato",
   "name": "Latte Macchiato"
   }
 ]
}

Freedom for the Server and the Client

It is important to note that different servers may expect different POST requests. One server might expect the schema:productID in the request body, another might just expect a POST to a URI without request body, yet another might prefer an object with an @id URI that identifies the desired product. A hydra client should work with this sort of variety across servers because if it does, it is free to work with multiple servers and cope with different expectations. There is no need to write a different client for a different server. Similarly, the client doesn't break if a server decides that it would like to change the way its clients should create an order.

The only requirement for clients is that they must generate the request dynamically as advised by the server, rather than hard-coding the request for a particular server. To require such a capability from a client is not uncommon at all, your browser does it all the time when it generates application/x-www-form-urlencoded requests.

If we stick to this constraint, the server is free to evolve and the client is free to choose any server.

Hydra doesn't have all necessary means to describe the request. Therefore we assume that a client for our restbucks coffee shop understands schema:PropertyValueSpecification when generating requests, since we are using schema.org anyway. The RDF Data Shapes WG has just recently made available an early editor's draft of SHACL[1] which does similar things and is a possible alternative to schema:PropertyValueSpecification.

Add-Ons for my Coffee

Assuming that the initial order has been created, the server can make additional offerings. Let's say we want to allow an additional shot of Espresso and we want to offer a breakfast special. Where do we attach the additional offerings?

Fortunately, schema:Product has a schema:offers property which holds "an offer to provide this item" which in turn can hold schema:addOn offers. So we can attach an offers property to the ordered product with an Offer having only schema:addOn items.

The response below allows to accept addOn offers for the ordered product.

{
   "@context": {
     "@vocab": "http://schema.org/",
     "hydra": "http://www.w3.org/ns/hydra/core#",
     "hydra:property": {"@type": "@vocab"}
   },
   "@type": "Order",
   "@id": "http://example.com/orders/1234",
   "orderedItem": [
     {
       "@type": "pto:Latte_macchiato",
       "name": "Latte Macchiato",
       "@id": "http://example.com/orders/1234/items/1",
       "offers": {
         "@type": "Offer",
         "addOn": [
           {
             "@type": "Offer",
             "itemOffered": {
               "@type": "Product",
               "name": "shot",
               "hydra:collection" : {
                 "@id": "http://example.com/orders/1234/items/1/addOns",
                 "hydra:manages": {
                   "hydra:property": "isAccessoryOrSparePartFor",
                   "hydra:object": {
                     "@id": "http://example.com/orders/1234/items/1",
                     "@type": "Product"
                   }
                 },
                 "hydra:operation": {
                   "hydra:httpMethod": "POST",
                   "@type": "OrderAction",
                   "hydra:expects": {
                     "@type": "Product",
                     "hydra:supportedProperty": [
                       {
                         "hydra:property": "name",
                         "hydra:required": true,
                         "defaultValue": "shot",
                         "readOnlyValue": true
                       }
                     ]
                   }
                 }
               }
             },
             "price": 0.2,
             "priceCurrency": "EUR"
           },
           {
             "@type": "Offer",
             "itemOffered": {
               "@type": "Product",
               "name": "Brioche con crema",
               "description": "Breakfast special",
               "hydra:collection" : {
                 "@id": "http://example.com/orders/1234/items/1/addOns",
                 "hydra:manages": {
                   "hydra:property": "isAccessoryOrSparePartFor",
                   "hydra:object": {
                     "@id": "http://example.com/orders/1234/items/1",
                     "@type": "Product"
                   }
                 },
                 "hydra:operation": {
                   "hydra:httpMethod": "POST",
                   "@type": "OrderAction",
                   "hydra:expects": {
                     "@type": "Product",
                     "hydra:supportedProperty": [
                       {
                         "hydra:property": "name",
                         "hydra:required": true,
                         "defaultValue": "Brioche con crema",
                         "readOnlyValue": true
                       }
                     ]
                   }
                 }
               }
             },
             "price": 1.00,
             "priceCurrency": "EUR"
           }
         ]
       }
     }
   ]
 }

The response to the possible addOn POSTs would be an updated order. Let's assume the client orders an additional shot:

POST /orders/1234/items/1/addOns HTTP/1.1
...
{
 "@context": "http://schema.org/",
 "name": "shot"
}

The server returns a schema:Order containing the latte macchiato with extra shot. In the updated order below you can find the extra shot at "$.orderedItem[0].extra". There is an attribute schema:isAccessoryOrSparePartFor which normally belongs on the accessory, but with @reverse we can define an "extra" property in the @context from it.

Of course, the customer can remove the extra shot again by using the DELETE method on the shot resource.

And something else has changed. Since Kaffeehaus Hagen only wants to offer one extra shot of Espresso per Latte Macchiato, the only addOn offer left is the Brioche con crema.

200 OK

{
 "@context": {
   "@vocab": "http://schema.org/",
   "hydra": "http://www.w3.org/ns/hydra/core#",
   "hydra:property": {"@type": "@vocab"},
   "extra": {"@reverse": "isAccessoryOrSparePartFor"}
 },
 "@type": "Order",
 "@id": "http://example.com/orders/1234",
 "orderedItem": [
   {
     "@type": "pto:Latte_macchiato",
     "name": "Latte Macchiato",
     "@id": "http://example.com/orders/1234/items/1",
     "extra": {
       "@type": "Product",
       "@id": "http://example.com/orders/1234/items/1/addOns/1",
       "name": "shot",
       "hydra:operation": {
         "hydra:httpMethod": "DELETE",
         "@type": "DeleteAction"
       }
     },
     "offers": {
       "@type": "Offer",
       "addOn": [
         {
           "@type": "Offer",
           "itemOffered": {
             "@type": "Product",
             "name": "Brioche con crema",
             "description": "Breakfast special",
             "hydra:operation": [
               {
                 "hydra:httpMethod": "POST",
                 "@type": "OrderAction",
                 "hydra:expects": {
                   "@type": "Product",
                   "hydra:supportedProperty": [
                     {
                       "hydra:property": "name",
                       "hydra:required": true,
                       "defaultValue": "Brioche con crema",
                       "readOnlyValue": true
                     }
                   ]
                 }
               }
             ]
           },
           "price": 1.00,
           "priceCurrency": "EUR"
         }
       ]
     }
   }
 ]
}

If the client orders the brioche, too, the Latte Macchiato would have no more addOn offers, because the breakfast special only allows for one brioche per hot beverage at a reduced price.

Continue Shopping

It is also possible to continue shopping. The challenge is that we want to offer the other items available in the store, but when the client orders one of them, it should POST it to the items of our existing order rather than creating a new order. The server has no idea that this anonymous customer has an ongoing order, so we have to make this state information available in the order response and its URLs.

We could simply change the orderedItem array to a hydra:Collection so that we could identify the collection of ordered items by its URI, and define a hydra:operation for it which expects a POST containing a product. However, the client somehow would have to memorize the products which were offered by the shop in the first place, so that it could use that information to POST.

To require less smartness from the client, we allow it to use the list of offers again, but this time contextualized for the current order.

We want to say that the seller of this order makes more offers. For this, we add a seller to the order, and the seller has a makesOffer property in turn. The offers are almost the same as in the original store, but the URI to post additional orders to is now the URI to the items of the current order. That way we ensure that additional items will be added to the current order rather than ending up as separate orders.

Note that the list of offers could also be externalized by converting the makesOffer array to a hydra:collection identified by its own URL, e.g. /orders/1234/offers.

{
 ...
 "@type": "Order",
 "@id": "http://example.com/orders/1234",
 "seller": {
   "@type": "CafeOrCoffeeShop",
   "@id": "http://example.com/store",
   "name": "Kaffeehaus Hagen",
   "makesOffer": [
     {
       "@type": "Offer",
       "itemOffered": {
         "@type": "pto:Latte_macchiato",
         "@id": "http://example.com/products/latte-1",
         "name": "Latte Macchiato",
         "hydra:collection": {
           "@id": "http://example.com/orders/1234/items",
           "hydra:manages" : {
             "hydra:property" : "orderedItem", 
             "hydra:subject" : "http://example.com/orders/1234", 
           }, 
           "hydra:operation": {
             "hydra:method": "POST",
             "@type": "OrderAction",
             "hydra:expects": {
               "hydra:supportedProperty": [
                 {
                   "@type": "PropertyValueSpecification",
                   "hydra:property": "productID",
                   "hydra:required": true,
                   "defaultValue": "latte-1",
                   "readOnlyValue": true
                 }
               ]
             },
             "hydra:returns": "Order"
           }
         }
       },
       "price": 2.8,
       "priceCurrency": "EUR"
     },
     {
       "@type": "Offer",
       "itemOffered": {
         "@type": "pto:Cappuccino",
         "@id": "http://example.com/products/cappuccio-1",
         "name": "Cappuccino",
         "hydra:collection": {
           "@id": "http://example.com/orders/1234/items",
           "hydra:manages" : {
             "hydra:property" : "orderedItem", 
             "hydra:subject" : "http://example.com/orders/1234", 
           }, 
           "hydra:operation": {
             "hydra:method": "POST",
             "@type": "OrderAction",
             "hydra:expects": {
               "hydra:supportedProperty": [
                 {
                   "@type": "PropertyValueSpecification",
                   "hydra:property": "productID",
                   "hydra:required": true,
                   "defaultValue": "cappuccio-1",
                   "readOnlyValue": true
                 }
               ]
             },
             "hydra:returns": "Order"
           }
         }
       },
       "price": 2.2,
       "priceCurrency": "EUR"
     }
   ]
   ...
}

Proceed to Checkout

At some point we need to pay for our order. We can enable the client to ask for the invoice by posting our order information to the "invoices" resource. The suitable schema.org property which relates an order to an invoice is partOfInvoice.

 { 
   "@context": {
     ...
     "hydra": "http://www.w3.org/ns/hydra/core#",
     "hydra:property": {"@type": "@vocab"}
   },
   ...
   "@type": "Order",
   "@id": "http://example.com/orders/1234",
   "hydra:collection": [
     {   
       "@type": "hydra:Collection",
       "@id": "http://example.com/invoices",
       "hydra:manages": {
           "hydra:property": "partOfInvoice",
           "hydra:subject": { "@id":"http://example.com/orders/1234" }
       },
       "hydra:operation": {
         "hydra:method": "POST",
         "hydra:expects": {
           "hydra:supportedProperty": [
             {
               "@type": "PropertyValueSpecification",
               "hydra:property": "orderNumber",
               "hydra:required": true,
               "defaultValue": "1234",
               "readOnlyValue": true
             }
           ]
         }
       }
     }
   ]
   ...
 }

The client asks for the invoice as shown below. Note that the client should generate the request body dynamically, based on the description of the expected properties, so that the server is free to change the expected request body in a later version.

POST /example.com/invoices HTTP/1.1
...
{
 "@context": "http://schema.org/",
 "orderNumber": "1234" 
}

The server responds with the actual invoice and is able to fill in the order details since it knows everything about order #1234.

201 Created
Location: http://example.com/invoices/5678

{
  "@context": {
    "@vocab": "http://schema.org/",
    "hydra": "...",
    "extra": {"@reverse": "isAccessoryOrSparePartFor"},
    ...
  }
  "@type": "Invoice",
  "@id": http://example.com/invoices/5678",
  "totalPaymentDue": {
    "@type": "PriceSpecification",
    "price": 3.0,
    "priceCurrency": "EUR"
  },
  "referencesOrder": {
    "@type": "Order",
    "@id": "http://example.com/orders/1234",
    "orderedItem": [
    {
      "@type": "pto:Latte_macchiato",
      "name": "Latte Macchiato",
      "@id": "http://example.com/orders/1234/items/1",
      "extra": {
        "@type": "Product",
        "@id": "http://example.com/orders/1234/items/1/addOns/1",
        "name": "shot",
      },
    }
  }
}

The List of Drinks for the Barista

The barista needs to access the list of orders. While there is no property in schema.org which points from a shop to related orders, there is again a property to express the opposite: an Order can have a related seller. We have already used it on our order in the 'continue shopping' use case.

Now we are going to use it on the store. In this case the order is the subject, the seller property is the predicate and the shop is the object.

We add a collection link to the shop that allows the barista to retrieve the orders:

 {
   ...
   "@type": "CafeOrCoffeeShop",
   "@id": "http://example.com/store",
   ...
   "hydra:collection": {
     "@id": "http://example.com/orders",
     "hydra:manages": {
       "hydra:property": "seller",
       "hydra:object": {"@id": "http://example.com/store"} 
     },
     "hydra:search": {
       "@type": "hydra:IriTemplate",
       "hydra:template": "http://example.com/orders{?status}",
       "hydra:mapping": [
         {
             "hydra:variable": "status",
             "hydra:required": true,
             "hydra:property": "orderStatus"
         }
       ]
     }
   }
 }

The manages block with the property schema:seller only says that the orders belong to the shop, it doesn't restrict them by their status, and there is no property "ordersInProcess" to express that. But a schema:Order has an orderStatus which allows to find out if an order is still processing, so we can use that property to restrict the orders to those which are in status OrderProcessing. The client could filter the orders by their status on its own, but we want to offer a way to ask for orders which are still in process. For this, we add a hydra:search property to the collection and tell the client that it may search by orderStatus. Which values are allowed for orderedStatus is well defined, since orderStatus expects one of the values defined by the enumeration OrderStatus. The URL of OrderProcessing is http://schema.org/OrderProcessing, hence the client may query:

http://example.com/orders?status=http%3A%2F%2Fschema.org%2FOrderProcessing

Sometimes it would be useful to tell the client exactly which values make sense in a certain context. Neither Hydra nor schema:PropertyValueSpecification can express that, but SHACL[1] proposes sh:in for this purpose. However, the integration with SHACL is not as straightforward as with PropertyValueSpecification.

Related Resources

[1] The SHACL published draft is at http://www.w3.org/TR/shacl/. The latest editor's draft can be found at http://w3c.github.io/data-shapes/shacl/. For an overview see the RDF Data Shapes Wiki. SHACL is under active development, and the exact relationship with Hydra has not yet been discussed thoroughly by the Hydra CG.