Chapter 5

Using HTTP Verbs

We just went through so much and we only created two endpoints in our API! However, reviewing all those basics was fundamental for our progress and what we will be learning next. Don’t get too excited yet - we still have a few things to learn about HTTP.

5.1. HTTP Methods, a.k.a “Verbs”

One of the most underrated features of HTTP is its methods. Why underrated? Because browsers, the most used web API clients in the world, and the HTML format, the most used web format in the world, don’t make full use of them.

We’ve seen before that websites are just web APIs with a web browser as client, outputting HTML instead of JSON or XML. Still, we have two pieces of software talking to each other, a web browser and a web server. Sadly, HTML only supports two HTTP methods: GET and POST. You already know GET, as we’ve used it a few times since the beginning of this module. But do you really know what HTTP methods are? Do you know what idempotent means or why some methods are classified as ‘safe’?

5.1.1. What Is An HTTP Method?

HTTP methods, also known as HTTP verbs, are just English verbs like GET which define what action the client wants to be performed on the identified resource. Each method defines exactly what the client wants to happen when sent to a server, but as always, it all depends on the server. If a server implementation doesn’t follow the HTTP RFC recommendations, you will need to study the documentation for this server application and adapt. That’s why following web standards when you build a web API is so important, you don’t want to leave your users having to figure out how everything works in your specific case, instead of just resorting to their general knowledge of HTTP.

In the original specification of HTTP (version 1.0), only three of those methods were defined: GET, POST and HEAD. Five new verbs were added to the revision of HTTP (version 1.1): OPTIONS, PUT, DELETE, TRACE and CONNECT. The RFC 5789 extends HTTP with a new method: PATCH. This adds up to nine different HTTP methods, but we will only use half of them most of the time.

5.1.2. What Is A “Safe” Method?

When working with a web API, you sometimes need to create or destroy some data. Other times, you just need to retrieve some data without being destructive.

That’s exactly why some methods are considered “safe”; they should not have any side effects and should only retrieve data. You can, of course, implement something in your web API when a safe method is called, like updating that user’s quota for example. However, the client cannot be held responsible for those modifications since he did not request them and considered the method safe to use.

The only safe methods are GET and HEAD.

For example, if I run the request GET /users in our Sinatra API, there is no side effect. I will just keep getting a list of users and I can run it as many times as I want. This leads me to idempotence.

5.1.3. What Is An ‘Idempotent’ Method?

Idempotence is a funky word; I had no idea what it meant until I stumbled upon it in the HTTP RFC document. Idempotence is the property of some operations in mathematics and computer science, where running the same operation multiple times will not change the result after the initial run. It has the same meaning in the HTTP context.

The impact of sending 10 HTTP requests with an idempotent method is the same as sending just one request.

The idempotent methods are GET, HEAD, PUT, DELETE, OPTIONS and TRACE.

If I send the same GET request multiple times, I should just get the same representation, over and over again. There are some situations that can remove the idempotence property from a method, like when an error arises or the user’s quota is full. We need to keep a pragmatic mindset towards idempotence when building web APIs.

It’s the same with DELETE, which shouldn’t raise an error when a client tries to delete a resource that has already been deleted. However, I’m not sure that’s the best thing to do since, from a pragmatic standpoint, you’d like to let the client know that it was already deleted and it can stop sending the same request.

We already used GET in our little API. Now that we’ve learned that there are more methods, it’s time to implement them!

5.2. Using All HTTP Verbs In Our API

5.2.1. GET

GET is safe and idempotent. This method is meant to retrieve information from the specified resource in the form of a representation.

The GET method can also be used as a “conditional GET” when conditional header fields are used and as ‘partial GET’ when the Range header field is used.

As a reminder, here is our GET endpoint for the users resource.

get '/users' do
  type = accepted_media_type

  if type == 'json'
    content_type 'application/json'
    users.map { |name, data| data.merge(id: name) }.to_json
  elsif type == 'xml'
    content_type 'application/xml'
    Gyoku.xml(users: users)
  end
end

We are also going to need a way to retrieve specific users; to do this, we need to have one URI available for each user. Each user will be a resource and we will get representations for that user specifically. Luckily, we don’t have to hardcode each URI as /users/thibault, /users/john and so on, that would be a pain to do! We can just use a match URI like /users/:first_name.

Below is the code for this new endpoint.

# webapi.rb
# ...
# get '/users'

get '/users/:first_name' do |first_name|
  type = accepted_media_type

  if type == 'json'
    content_type 'application/json'
    users[first_name.to_sym].merge(id: first_name).to_json
  elsif type == 'xml'
    content_type 'application/xml'
    Gyoku.xml(first_name => users[first_name.to_sym])
  end
end

It is very similar to get '/users', the main difference being the data that we send back to the client. Here we select the wanted user from our data hash and send it back to the client after serializing it. For JSON, we use users[first_name].merge(id: first_name).to_json and for XML Gyoku.xml(first_name => users[first_name]).

Let’s do one curl request for each available format. First, stop your server if it’s running and restart it with ruby webapi.rb.

With JSON, the default format:

curl http://localhost:4567/users/thibault

Output

{"first_name":"Thibault", "last_name":"Denizet", "age":25, "id":"thibault"}

With XML:

curl -i http://localhost:4567/users/thibault \
     -H "Accept: application/xml"

Output

<thibault>
  <firstName>Thibault</firstName><lastName>Denizet</lastName><age>25</age>
</thibault>

Looks good! Let’s proceed.

Don’t try to query for users that don’t exist yet; we are not handling errors yet! There will be a full section about that in the next chapter.

5.2.2. HEAD

The HEAD method works in pretty much the same way as the GET method. The only difference is that the server is not supposed to return a body after receiving a HEAD request. The HTTP headers received back by the client should be exactly like the ones it would receive from a GET request.

HEAD is safe and idempotent. It can be used to test a URI before actually sending a request to retrieve data.

Let’s add support for this method in our web API. We can first duplicate the get '/users' route. There are only two changes to make after that. Changing get to head and preventing anybody from being sent, by removing:

users.map { |name, data| data.merge(id: name) }.to_json

And:

Gyoku.xml(users: users)

We end up with the following, which will send back the same headers as a GET request, minus the body:

# webapi.rb
# ...
# get '/users'
# get '/users/:first_name'

head '/users' do
  type = accepted_media_type

  if type == 'json'
    content_type 'application/json'
  elsif type == 'xml'
    content_type 'application/xml'
  end
end

Restart your server after adding this route.

To run a HEAD request with curl, we need to use the -I option. Simply using the -X option and setting it to HEAD (-X HEAD) will correctly send a HEAD request, but it will then wait for data to be received. Let’s also use the verbose option (-v) to see if the sent request meets our expectations.

curl -I -v http://localhost:4567/users

Here’s the request sent…

HEAD /users HTTP/1.1
Host: localhost:4567
User-Agent: curl/7.43.0
Accept: */*

and the response received:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 162
X-Content-Type-Options: nosniff
Connection: keep-alive
Server: thin

All good! It seems we correctly followed the HTTP RFC to implement our HEAD endpoint. The code is a big duplication of the GET endpoint; don’t worry, we will refactor that once we have all our endpoints. For now, let’s focus on learning more about the remaining methods.

5.2.3. POST

POST is as famous as GET because it’s the only other HTTP method supported by the HTML format. Building web applications usually revolves around using either GET to get some HTML documents or POST to post some form of data that will do anything from creating to updating to deleting entities.

Well, that doesn’t really follow what’s defined in the HTTP RFC, unfortunately. POST is supposed to be used only to create new entities, be it as a new database record or as an annotation of an existing resource.

Let’s focus on the POST method as a means to create a new record of the resource identified in the request. It is neither safe nor idempotent to create new records.

The RFC also specifies how to respond to the client by using specific status codes like 200 (OK), 204 (No Content) or 201 (Created). The first two codes should only be used if the created entity cannot be identified by a URI; 200 when the response contains a body and 204 when it does not. Otherwise, 201 should be returned with the Location header set to the URI of this new entity.

Box 5.1. Sending back the new entity data or not?

A best practice is to return the newly created entity as the body of a POST request. It should only be done if the data of the entity that was created and the data that was sent with the request are different. For example, if a new field like created_at was added during the creation, then sending back the entity right away makes sense, and saves the client from making another call with the URI present in the Location header. In our Sinatra API, we will be returning 201 without a body, but when we work on our next API we will start sending back entities right away.

You can also decide to send back

Let’s add a POST endpoint to create new users in our API. Since we don’t persist anything, we will just add the sent user into the users hash. We are not going to handle any error for now, so if you send something that’s not a valid JSON, it will crash.

Don’t worry - we’ll add error handling very soon.

You can find the code for this endpoint below. In the code, we first get the payload of the request with request.body.read that we then parse to get a Ruby hash from. Then we store the received user in our users hash using the user first_name as key. Finally, we send back 201 to the client to confirm the creation.

# webapi.rb
# ...
# get '/users'
# get '/users/:first_name'
# head '/users'

post '/users' do
  user = JSON.parse(request.body.read)
  users[user['first_name'].downcase.to_sym] = user

  url = "http://localhost:4567/users/#{user['first_name']}"
  response.headers['Location'] = url   

  status 201
end

Restart your server after adding this route.

Let’s give it a try with curl.

curl -X POST -v http://localhost:4567/users \
     -H "Content-Type: application/json" \
     -d '{"first_name":"Samuel","last_name":"Da Costa","age":19}'

Request Sent:

POST /users HTTP/1.1
Host: localhost:4567
User-Agent: curl/7.43.0
Accept: */*
Content-Type: application/json
Content-Length: 55

.. {JSON data} ...

Response Received:

HTTP/1.1 201 Created
Content-Type: text/html;charset=utf-8
Location: http://localhost:4567/users/Samuel
Content-Length: 0
... Hidden Headers ...

Awesome, everything seems to be working fine! We can double-check by getting our list of users.

curl http://localhost:4567/users
[
  {"first_name":"Thibault", "last_name":"Denizet", "age":25},
  {"first_name":"Simon", "last_name":"Random", "age":26},
  {"first_name":"John", "last_name":"Smith", "age":28},
  {"first_name":"Samuel", "last_name":"Da Costa", "age":19}
]

Great, Samuel is there!

No Samuel was harmed in the making of this HTTP request.

5.2.4. PUT

PUT, just like POST, is not safe; however it is idempotent. This might surprise you since PUT is often used to send entity updates to the server, as in Rails before Rails 4 was released.

The truth is that PUT can be used by a client to tell the server to store the given entity at the specified URI. This will not just update it, but will completely replace the entity available at that URI with the one supplied by the client.

PUT can also be used to create a new entity at the specified URI if there is no entity identified by this URI yet. In such a case, the server should create it.

In most situations, the main difference between PUT and POST is that POST uses a URI like /users, which identifies the users resource, whereas PUT works with a URI such as /users/1, which identifies one specific user as a resource.

It would be totally possible to send a PUT /users request with a list of users to replace all the users currently stored. However, in most scenarios, it doesn’t really make sense to do this kind of thing.

Let’s implement it in our web API. The code looks similar to the POST endpoint, but in this case, we don’t need to set the Location header (the client is already calling that user endpoint with /users/thibault).

Once again, we are returning 204 No Content or 201 Created and not 200 OK because we are not sending back any representation of the resource since the client already has the same data for that entity.

# webapi.rb
# ...
# get '/users'
# get '/users/:first_name'
# head '/users'
# post '/users'

put '/users/:first_name' do |first_name|
  user = JSON.parse(request.body.read)
  existing = users[first_name.to_sym]
  users[first_name.to_sym] = user
  status existing ? 204 : 201
end

Restart your server after adding this route.

Notice how we either return 204 No Content or 201 Created, depending on the user entity we received and whether it already exists or not, by defining the existing variable. You should also understand that we completely replace all the data with what the client has sent instead of just updating it inside the users hash.

Now it’s time to run some curl requests to see if everything is working as expected. First, we are going to create a user at the URI /users/jane.

curl -X PUT -v http://localhost:4567/users/jane \
     -H "Content-Type: application/json" \
     -d '{"first_name":"Jane","last_name":"Smiht","age":24}'

Request Sent:

PUT /users/Jane HTTP/1.1
Host: localhost:4567
User-Agent: curl/7.43.0
Accept: */*
Content-Type: application/json
Content-Length: 50

upload completely sent off: 50 out of 50 bytes

Response Received:

HTTP/1.1 201 Created
Content-Type: text/html;charset=utf-8
Content-Length: 0
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Connection: keep-alive
Server: thin

Great, it seems Jane was saved correctly. Let’s double-check that by accessing /users/Jane.

curl http://localhost:4567/users/jane

Output

{"first_name":"Jane", "last_name":"Smiht", "age":24, "id":"jane"}

Wait! There is a typo in her name. Plus she is not 24, she is actually 25. We need to quickly fix this! Let’s send another request with the correct values.

curl -X PUT -v http://localhost:4567/users/jane \
     -H "Content-Type: application/json" \
     -d '{"first_name":"Jane","last_name":"Smith","age":25}'

Request Sent:

PUT /users/jane HTTP/1.1
Host: localhost:4567
User-Agent: curl/7.43.0
Accept: */*
Content-Type: application/json
Content-Length: 50

upload completely sent off: 50 out of 50 bytes

Response Received:

HTTP/1.1 204 No Content
X-Content-Type-Options: nosniff
Connection: close
Server: thin

Alright, looks good! We can double-check one last time by asking for a representation of the Jane resource.

curl http://localhost:4567/users/jane

Output

{"first_name": "Jane", "last_name":"Smith", "age":25, "id":"jane"}

Our PUT endpoint is working well. It can either create a new entity if the entity does not already exist, or override all the values for an existing one.

5.2.5. PATCH

PATCH is a bit special. This method is not part of the HTTP RFC 2616, but was later defined in the RFC 5789 in order to do “partial resource modification.” It is basically a way for the client to change only some specific values for an entity instead of having to resend the whole entity data.

While PUT only allows the complete replacement of a document, PATCH allows the partial modification of a resource.

Just like PUT, it uses a unique entity URI like /users/1 and the server can create a new entity if there is none that exists yet. However, in my opinion, it’s better to keep POST or PUT for creation and only use PATCH for updates. That’s why in our web API, the PATCH endpoint for a user resource will only be able to update its data.

It would also be possible to send PATCH requests to a URI like /users to do partial modifications to more than one user in one request.

By default, PATCH is neither safe nor idempotent.

PATCH requests can be made idempotent in order to avoid conflicts between multiple updates by using two headers, Etag and If-Match, to make conditional requests. These headers will contain a value to ensure that the client and the server have the same version of the entity and prevent an update if they have different versions. This is not required for every operation, only for the ones where a conflict is possible. For example, adding a line to a log file through a PATCH request does not require checking for conflict.

We will learn how to avoid conflicts later, when we build our full-scale Rails API. For now, we both know we won’t create any conflicts running one request at a time with curl. It’s so annoying having to copy/paste the serialization part, and I can’t wait for the refactoring that we will be doing soon.

The code for the PATCH endpoint is a bit long, but we will be able to refactor it after we are done reviewing all the HTTP methods.

# webapi.rb
# ...
# get '/users'
# get '/users/:first_name'
# head '/users'
# post '/users'
# put '/users/:first_name'

patch '/users/:first_name' do |first_name|
  type = accepted_media_type

  user_client = JSON.parse(request.body.read)
  user_server = users[first_name.to_sym]

  user_client.each do |key, value|
    user_server[key.to_sym] = value
  end

  if type == 'json'
    content_type 'application/json'
    user_server.merge(id: first_name).to_json
  elsif type == 'xml'
    content_type 'application/xml'
    Gyoku.xml(first_name => user_server)
  end
end

Restart your server after adding this route.

I’m going to be 26 soon, so let’s update my age in our super-fast memory database (a.k.a a Ruby hash).

curl -X PATCH -v http://localhost:4567/users/thibault \
     -H "Content-Type: application/json" \
     -d '{"age":26}'

Request Sent:

PATCH /users/thibault HTTP/1.1
Host: localhost:4567
User-Agent: curl/7.43.0
Accept: */*
Content-Type: application/json
Content-Length: 10

upload completely sent off: 10 out of 10 bytes

Response Received:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 56
X-Content-Type-Options: nosniff
Connection: keep-alive
Server: thin

{"first_name":"Thibault", "last_name":"Denizet", "age":26, "id":"thibault"}

We just confirmed that our PATCH endpoint allows us to cherry-pick what we want to update on the thibault resource.

Since we only send what we want to update in an atomic way, PATCH requests are usually smaller in size than the PUT ones. This can have a positive impact on the performances of web APIs.

5.2.6. DELETE

The DELETE method is used by a client to ask the server to delete the resource identified by the specified URI. The server should only tell the client that the operation was successful in case it’s really going to delete the resource or at least move it to an inaccessible location. The server should send back 200 OK if it has some content to transfer back to the client or simply 204 No Content if everything went as planned, but the server has nothing to show for it. There is also the option of returning 202 Accepted if the server is planning to delete the resource later, but hasn’t had time to do it before the response was sent back.

For our little API, we will delete the user and return 204 No Content.

# webapi.rb
# ...
# get '/users'
# get '/users/:first_name'
# head '/users'
# post '/users'
# put '/users/:first_name'
# patch '/users/:first_name'

delete '/users/:first_name' do |first_name|
  users.delete(first_name.to_sym)
  status 204
end

Restart your server after adding this route.

Give it a try with this curl request.

curl -X DELETE -v http://localhost:4567/users/thibault

Request Sent:

DELETE /users/thibault HTTP/1.1
Host: localhost:4567
User-Agent: curl/7.43.0
Accept: */*

Response Received:

HTTP/1.1 204 No Content
X-Content-Type-Options: nosniff
Connection: close
Server: thin

We can then request the list of all users:

curl http://localhost:4567/users

Output

[
  {"first_name":"Simon", "last_name":"Random", "age":26},
  {"first_name":"John", "last_name":"Smith", "age":28}
]

Thibault is nowhere to be found, our DELETE request did its job. We finally got rid of him!

5.2.7. OPTIONS

The OPTIONS method is a way for the client to ask the server what the requirements are for the identified resource. For example, OPTIONS can be used to ask a server what HTTP methods can be applied to the identified resource or which source URL is allowed to communicate with that resource.

Box 5.2. The OPTIONS method and JavaScript CORS issues

Cross-origin HTTP requests are initiated from one domain (for example, a.com), and sent to another domain (b.com). Stylesheets and images can be loaded from other domain names with cross-origin requests. However, browsers prevent such requests from happening from within scripts (except for GET, HEAD and POST requests) and only if the server replies with the Access-Control-Allow-Origin header that allows the sending domain.

For other methods, like PUT or PATCH, a preflight request will be sent with the OPTIONS method in order to verify that the server allows this origin to perform this kind of action. We will setup CORS in the next module when we build a Rails 5 API.

It’s important to know about this HTTP method if you are building Single Page Applications with JavaScript frameworks like Angular.js or React.

Minimally, a server should respond with 200 OK to an OPTIONS request and include the Allow header that contains the list of supported HTTP methods. Optimally, the server should send back the details of the possible operations in a documented style.

When requested with OPTIONS, our resources should just send back the list of allowed HTTP methods in the Allow header. In the second module, we will learn more about how to handle CORS and the family of headers starting with Access-Control-Allow-XXX.

We need to add two routes in our Sinatra API: one for the users resource and one that will match any specific user resource.

# webapi.rb
# ...
# get '/users'
# get '/users/:first_name'
# head '/users'
# post '/users'
# put '/users/:first_name'
# patch '/users/:first_name'
# delete '/users/:first_name'

options '/users' do
  response.headers['Allow'] = 'HEAD,GET,POST'
  status 200
end

options '/users/:first_name' do
  response.headers['Allow'] = 'GET,PUT,PATCH,DELETE'
  status 200
end

Restart your server after adding these routes.

Let’s give it a try, as usual, with a quick curl request.

curl -v -X OPTIONS http://localhost:4567/users

Request Sent:

OPTIONS /users HTTP/1.1
Host: localhost:4567
User-Agent: curl/7.43.0
Accept: */*

Response Received:

HTTP/1.1 200 OK
Allow: HEAD,GET,POST
[More Headers]

If we didn’t know how the server was built, this request would let us know that we can send three types of requests to the resource identified by /users: HEAD, GET or POST. By associating that with what we know about HTTP, we can already figure out that we can retrieve or create new users!

Let’s try with the route for a specific user now.

curl -i -X OPTIONS http://localhost:4567/users/thibault

Output

HTTP/1.1 200 OK
Allow: GET,PUT,PATCH,DELETE
[More Headers]

Alright, seems we’re done with OPTIONS and everything is running smoothly.

5.2.8. TRACE & CONNECT

We are getting to the HTTP methods that are very rarely used. Actually, we won’t be using any of these two methods since they are far from being useful when creating a web API. They are only meant to be used with HTTP proxies and won’t be covered in this book.

5.2.9. LINK & UNLINK

LINK and UNLINK are two new HTTP methods defined in an Internet-Draft. The idea behind those two methods is to allow the creation of relationships between entities. We will learn more about those two headers later in this book.

Box 5.3. a ||= b

a ||= b is the equivalent of a || a = b and not a = a || b, as some people think. Indeed, if a already holds a value, why re-assign it?

5.3. A Global Overview

We are done reviewing and integrating almost all the HTTP verbs into our Sinatra API; next, we’ll add error handling. Indeed, while building our endpoints, we forgot to consider that clients can make mistakes or just be plain deceptive. We need to protect ourselves against things like invalid data or missing users.

But first, we need to refactor our code! There is currently a lot of repeated code - and since we all love clean code, we urgently need to fix it.

There are two things we can improve quickly. First, we can extract the method type = accepted_media_type that is at the beginning of some of our requests into a method that will load it when we need it and then just reuse it: @type ||= accepted_media_type.

The second thing we can change is the code that sends back either JSON or XML to the client. The same piece of code is duplicated in multiple places, so we are going to extract it and put it inside a method. See below:

def send_data(data = {})
  if type == 'json'
    content_type 'application/json'
    data[:json].call.to_json if data[:json]
  elsif type == 'xml'
    content_type 'application/xml'
    Gyoku.xml(data[:xml].call) if data[:xml]
  end
end

In this method, we expect to have either the :json key, the :xml key or none of them. The values for those keys should be lambdas that contain the data we want to send back. Why lambdas? So we don’t have to build multiple objects that wouldn’t be used.

Here is the complete code with the bit of refactoring I just mentioned. I also reorganized the methods to have all the endpoints related to /users first, followed by the endpoints for /users/:first_name.

# webapi.rb
require 'sinatra'
require 'json'
require 'gyoku'

users = {
  thibault: { first_name: 'Thibault', last_name: 'Denizet', age: 25 },
  simon:    { first_name: 'Simon', last_name: 'Random', age: 26 },
  john:     { first_name: 'John', last_name: 'Smith', age: 28 }
}

helpers do

  def json_or_default?(type)
    ['application/json', 'application/*', '*/*'].include?(type.to_s)
  end

  def xml?(type)
    type.to_s == 'application/xml'
  end

  def accepted_media_type
    return 'json' unless request.accept.any?

    request.accept.each do |type|
      return 'json' if json_or_default?(type)
      return 'xml' if xml?(type)
    end

    halt 406, 'Not Acceptable'
  end

  def type
    @type ||= accepted_media_type
  end

  def send_data(data = {})
    if type == 'json'
      content_type 'application/json'
      data[:json].call.to_json if data[:json]
    elsif type == 'xml'
      content_type 'application/xml'
      Gyoku.xml(data[:xml].call) if data[:xml]
    end
  end

end

get '/' do
  'Master Ruby Web APIs - Chapter 2'
end

# /users
options '/users' do
  response.headers['Allow'] = 'HEAD,GET,POST'
  status 200
end

head '/users' do
  send_data
end

get '/users' do
  send_data(json: -> { users.map { |name, data| data.merge(id: name) } },
            xml:  -> { { users: users } })
end

post '/users' do
  user = JSON.parse(request.body.read)
  users[user['first_name'].downcase.to_sym] = user

  url = "http://localhost:4567/users/#{user['first_name']}"
  response.headers['Location'] = url
  status 201
end

# /users/:first_name
options '/users/:first_name' do
  response.headers['Allow'] = 'GET,PUT,PATCH,DELETE'
  status 200
end

get '/users/:first_name' do |first_name|
  send_data(json: -> { users[first_name.to_sym].merge(id: first_name) },
            xml:  -> { { first_name => users[first_name.to_sym] } })
end

put '/users/:first_name' do |first_name|
  user = JSON.parse(request.body.read)
  existing = users[first_name.to_sym]
  users[first_name.to_sym] = user
  status existing ? 204 : 201
end

patch '/users/:first_name' do |first_name|
  user_client = JSON.parse(request.body.read)
  user_server = users[first_name.to_sym]

  user_client.each do |key, value|
    user_server[key.to_sym] = value
  end

  send_data(json: -> { user_server.merge(id: first_name) },
            xml:  -> { { first_name => user_server } })
end

delete '/users/:first_name' do |first_name|
  users.delete(first_name.to_sym)
  status 204
end

Here is a list of curl requests to ensure that everything is still working well. It would be even better had we written some automated tests…

GET /users

curl -i http://localhost:4567/users

Output

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 273
X-Content-Type-Options: nosniff
Connection: keep-alive
Server: thin

[
  {"first_name":"Thibault", "last_name":"Denizet", "age":25, "id":"thibault"},
  {"first_name":"Simon", "last_name":"Random", "age":26, "id":"simon"},
  {"first_name":"John", "last_name":"Smith", "age":28, "id":"john"}
]

POST /users

curl -X POST -i http://localhost:4567/users \
     -H "Content-Type: application/json" \
     -d '{"first_name":"Samuel", "last_name":"Da Costa", "age":19}'

Output

HTTP/1.1 201 Created
Content-Type: text/html;charset=utf-8
Location: http://localhost:4567/users/Samuel
Content-Length: 0
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Connection: keep-alive
Server: thin

PUT /users/jane

curl -X PUT -i http://localhost:4567/users/jane \
     -H "Content-Type: application/json" \
     -d '{"first_name":"Jane", "last_name":"Smith", "age":25}'

Output

HTTP/1.1 201 Created
Content-Type: text/html;charset=utf-8
Content-Length: 0
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Connection: keep-alive
Server: thin

GET /users/jane

curl -i http://localhost:4567/users/jane

Output

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 62
X-Content-Type-Options: nosniff
Connection: keep-alive
Server: thin

{"first_name":"Jane", "last_name":"Smith", "age":25, "id":"jane"}

PATCH /users/thibault

curl -X PATCH -i http://localhost:4567/users/thibault \
     -H "Content-Type: application/json" \
     -d '{"age":26}'

Output

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 72
X-Content-Type-Options: nosniff
Connection: keep-alive
Server: thin

{"first_name":"Thibault", "last_name":"Denizet", "age":26, "id":"thibault"}

DELETE /users/thibault

curl -X DELETE -i http://localhost:4567/users/thibault

Output

HTTP/1.1 204 No Content
X-Content-Type-Options: nosniff
Connection: close
Server: thin
curl -i http://localhost:4567/users

Output

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 263
X-Content-Type-Options: nosniff
Connection: keep-alive
Server: thin

[
  {"first_name":"Simon", "last_name":"Random", "age":26, "id":"simon"},
  {"first_name":"John", "last_name":"Smith", "age":28, "id":"john"},
  {"first_name":"Samuel", "last_name":"Da Costa", "age":19, "id":"samuel"},
  {"first_name":"Jane", "last_name":"Smith", "age":25, "id":"jane"}
]

OPTIONS /users

curl -i -X OPTIONS http://localhost:4567/users

Output

HTTP/1.1 200 OK
Content-Type: text/html;charset=utf-8
Allow: HEAD,GET,POST
Content-Length: 0
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Connection: keep-alive
Server: thin

Everything looks good. Now our API actually looks like something!

5.4. Wrap Up

In this chapter, we’ve learned what HTTP methods are and how they are supposed to be used. We also used curl extensively in order to test all that. Now it’s time to handle some errors!