Build a JSON API
This guide covers how to add JSON endpoints for the Article model introduced in the tutorial. These endpoints make it possible to list, retrieve, create, update, and delete articles by leveraging regular handlers, schemas, and the #json helper.
It should be noted that generic handlers such as Marten::Handlers::RecordCreate render a template and redirect once a schema is valid. The endpoints below return JSON responses, which is why they inherit from Marten::Handler directly. The schema validates the incoming payload, and the handler decides which fields are included in the response.
Requirements
The examples below assume that you already created the Article model and the ArticleSchema schema from the tutorial:
class Article < Marten::Model
field :id, :big_int, primary_key: true, auto: true
field :title, :string, max_size: 255
field :content, :text
end
class ArticleSchema < Marten::Schema
field :title, :string, max_size: 255
field :content, :string
end
Handlers defined under src/handlers/ are automatically required by projects that were generated through the new management command (require "./handlers/**" in src/project.cr).
Finally, it should be noted that one route maps to one handler. As such, the HTTP methods that share a URL are implemented as methods of the same handler class. Listing and creating articles share GET and POST on /api/articles, while retrieving, updating, and deleting an article share GET, PUT, and DELETE on /api/articles/<pk>.
Listing and creating articles
In order to list existing articles and to create new ones, let's create a src/handlers/api/articles_handler.cr file with the following content:
class API::ArticlesHandler < Marten::Handler
def get
articles = Article.all.map { |article| article_json(article) }
json({articles: articles})
end
def post
schema = ArticleSchema.new(request.data)
if schema.valid?
article = Article.new(schema.validated_data)
article.save!
json(article_json(article), status: 201)
else
json({errors: schema.errors}, status: 422)
end
end
private def article_json(article : Article)
{id: article.id, title: article.title, content: article.content}
end
end
This handler is able to handle both GET and POST requests:
- when the incoming request is a GET, it loads every
Articlerecord and returns a JSON object containing theid,title, andcontentof each article - when the incoming request is a POST, it initializes an
ArticleSchemaschema with the data currently present in the request object (which is returned by the#datamethod). This data is automatically initialized from a JSON object when theContent-Typeof the request isapplication/json. If the schema is valid, a newArticlerecord is created using the schema's validated data and returned with a201status code. Otherwise, the schema errors are returned with a422status code
For example, submitting a payload without a title produces a response like the following one:
{
"errors": [
{"field": "title", "type": "required", "message": "This field is required."}
]
}
It should be noted that the field attribute is null when the error is global. Model validation errors use the same representation, which means that article.errors can be passed to #json in the same way. Please refer to Serializing errors as JSON to learn more about this.
Retrieving, updating, and deleting an article
Let's now add the ability to retrieve, update, and delete a specific article. In this light, let's create a src/handlers/api/article_handler.cr file with the following content:
class API::ArticleHandler < Marten::Handler
@article : Article?
def get
json(article_json(article))
end
def put
schema = ArticleSchema.new(request.data)
if schema.valid?
article.update!(schema.validated_data)
json(article_json(article))
else
json({errors: schema.errors}, status: 422)
end
end
def delete
article.delete
head :no_content
end
private def article
@article ||= Article.get!(id: params["pk"])
rescue Marten::DB::Errors::RecordNotFound
raise Marten::HTTP::Errors::NotFound.new("Article not found")
end
private def article_json(article : Article)
{id: article.id, title: article.title, content: article.content}
end
end
This handler is able to handle GET, PUT, and DELETE requests:
- when the incoming request is a GET, it retrieves the
Articlerecord identified by thepkroute parameter and returns it as JSON - when the incoming request is a PUT, it validates the incoming JSON payload with the same
ArticleSchemaschema. If the schema is valid, the record is updated using the schema's validated data. Otherwise, the schema errors are returned with a422status code - when the incoming request is a DELETE, it deletes the record and returns an empty response with a
204status code by leveraging the#headhelper
In the above snippet, the #article method fetches the Article record by using the pk parameter value. If no record matches this primary key, a Marten::DB::Errors::RecordNotFound exception is rescued and a 404 Not Found response is returned. This is the same approach as the one used in the tutorial.
Mapping the handlers to routes
We can now map these handlers to proper routes. We can do this by editing the config/routes.cr file as follows:
Marten.routes.draw do
path "/", HomeHandler, name: "home"
path "/article/create", ArticleCreateHandler, name: "article_create"
path "/article/<pk:int>", ArticleDetailHandler, name: "article_detail"
path "/article/<pk:int>/update", ArticleUpdateHandler, name: "article_update"
path "/article/<pk:int>/delete", ArticleDeleteHandler, name: "article_delete"
path "/api/articles", API::ArticlesHandler, name: "api_articles"
path "/api/articles/<pk:int>", API::ArticleHandler, name: "api_article"
if Marten.env.development?
path "#{Marten.settings.assets.url}<path:path>", Marten::Handlers::Defaults::Development::ServeAsset, name: "asset"
path "#{Marten.settings.media_files.url}<path:path>", Marten::Handlers::Defaults::Development::ServeMediaFile, name: "media_file"
end
end
Cross-Site Request Forgery protection
The CSRF protection is enabled for these handlers, which is the default. A browser that submits an unsafe request (POST, PUT, or DELETE) and that sends the session cookie must include the CSRF token in the X-CSRF-Token header. Please refer to Using the CSRF protection with AJAX to learn more about how to retrieve this token and how to forward it.
A client that does not rely on the session cookie (for example another server) can disable the CSRF protection on a per-handler basis. This can be achieved through the use of the #protect_from_forgery class method:
class API::ArticlesHandler < Marten::Handler
protect_from_forgery false
# [...]
end
The same setting must be applied to API::ArticleHandler as well.
Trying the endpoints
You can now interact with these endpoints. For example, in order to list the available articles, you can run the following command:
curl http://localhost:8000/api/articles
In order to create a new article, you can run the following command:
curl http://localhost:8000/api/articles \
--request POST \
--header 'Content-Type: application/json' \
--header 'X-CSRF-Token: <token>' \
--data '{"title":"Hello","content":"First article"}'
The Content-Type header must be set to application/json. Otherwise the request body is not made available through the #data method.
The above request returns a 201 response like the following one:
{"id": 1, "title": "Hello", "content": "First article"}
Retrieving, updating, and deleting this article can be achieved by running the following commands:
curl http://localhost:8000/api/articles/1
curl http://localhost:8000/api/articles/1 \
--request PUT \
--header 'Content-Type: application/json' \
--header 'X-CSRF-Token: <token>' \
--data '{"title":"Hello","content":"Updated article"}'
curl http://localhost:8000/api/articles/1 \
--request DELETE \
--header 'X-CSRF-Token: <token>'
The X-CSRF-Token header can be omitted when the considered handlers explicitly disable the CSRF protection.