JSON и API
Исходный кодТребование: предполагается, что вы ознакомились с вводными руководствами и запустили приложение Phoenix и убедились, что оно работает.
Требование: предполагается, что вы ознакомились с руководством по контроллерам.
С помощью Phoenix Framework также можно создавать веб-API. По умолчанию Phoenix поддерживает JSON, но вы можете использовать любой другой формат представления данных.
JSON API
В этом руководстве мы создадим простой JSON API для хранения наших любимых ссылок. Он будет поддерживать все операции CRUD (создание, чтение, обновление и удаление) из коробки.
Для этого руководства мы воспользуемся генераторами Phoenix, чтобы создать структуру нашего API:
mix phx.gen.json Urls Url urls link:string title:string * creating lib/hello_web/controllers/url_controller.ex * creating lib/hello_web/controllers/url_json.ex * creating lib/hello_web/controllers/changeset_json.ex * creating test/hello_web/controllers/url_controller_test.exs * creating lib/hello_web/controllers/fallback_controller.ex * creating lib/hello/urls/url.ex * creating priv/repo/migrations/20221129120234_create_urls.exs * creating lib/hello/urls.ex * injecting lib/hello/urls.ex * creating test/hello/urls_test.exs * injecting test/hello/urls_test.exs * creating test/support/fixtures/urls_fixtures.ex * injecting test/support/fixtures/urls_fixtures.ex
Мы разделим эти файлы на четыре категории:
- Файлы в
lib/hello_webотвечают за непосредственное отображение JSON - Файлы в
lib/helloотвечают за определение контекста и логики сохранения ссылок в базе данных - Файлы в
priv/repo/migrationsотвечают за обновление базы данных - Файлы в
testпредназначены для тестирования контроллеров и контекстов
В этом руководстве мы рассмотрим только первую категорию файлов. Чтобы узнать больше о том, как Phoenix хранит данные и управляет ими, ознакомьтесь с руководством по Ecto и руководством по контекстам. У нас также есть целый раздел, посвящённый тестированию.
В конце генератор предлагает добавить ресурс /url в область :api в lib/hello_web/router.ex:
scope "/api", HelloWeb do pipe_through :api resources "/urls", UrlController, except: [:new, :edit] end
Для API используется конвейер :api, который выполняет определённые шаги, например проверяет, что клиент может обрабатывать ответы в формате JSON.
Затем нужно обновить репозиторий, выполнив миграции:
$ mix ecto.migrate
Проверка JSON API
Прежде чем вносить изменения в эти файлы, давайте посмотрим, как работает наш API из командной строки.
Сначала запустим сервер:
$ mix phx.server
Затем проверим, что наш API работает:
$ curl -i http://localhost:4000/api/urls
Если всё прошло по плану, мы должны получить ответ 200:
HTTP/1.1 200 OK
cache-control: max-age=0, private, must-revalidate
content-length: 11
content-type: application/json; charset=utf-8
date: Fri, 06 May 2022 21:22:42 GMT
server: Cowboy
x-request-id: Fuyg-wMl4S-hAfsAAAUk
{"data":[]}
Данные не вернулись, поскольку мы ещё ничего не добавили в базу данных. Давайте добавим несколько ссылок:
$ curl -iX POST http://localhost:4000/api/urls \
-H 'Content-Type: application/json' \
-d '{"url": {"link":"https://phoenixframework.org", "title":"Phoenix Framework"}}'
$ curl -iX POST http://localhost:4000/api/urls \
-H 'Content-Type: application/json' \
-d '{"url": {"link":"https://elixir-lang.org", "title":"Elixir"}}'
Теперь можно получить все ссылки:
$ curl -i http://localhost:4000/api/urls
Или получить ссылку по её id:
$ curl -i http://localhost:4000/api/urls/1
Затем обновим ссылку:
$ curl -iX PUT http://localhost:4000/api/urls/2 \
-H 'Content-Type: application/json' \
-d '{"url": {"title":"Elixir Programming Language"}}'
Ответом должен быть 200 с обновлённой ссылкой в теле.
Наконец, попробуем удалить ссылку:
$ curl -iX DELETE http://localhost:4000/api/urls/2 \ -H 'Content-Type: application/json'
Для подтверждения успешного удаления ссылки должен вернуться ответ 204.
Формирование JSON
Чтобы понять, как формировать JSON, начнём с действия index из UrlController, определённого в lib/hello_web/controllers/url_controller.ex:
def index(conn, _params) do
urls = Urls.list_urls()
render(conn, :index, urls: urls)
end
Как видим, это ничем не отличается от того, как Phoenix отображает HTML-шаблоны. Мы вызываем render/3, передавая соединение, шаблон, который должны отображать наши представления (:index), и данные, которые нужно сделать доступными для представлений.
Обычно в Phoenix для каждого формата представления используется отдельное представление. Для отображения HTML мы использовали бы UrlHTML. Теперь, когда мы формируем JSON, рядом с шаблоном в lib/hello_web/controllers/url_json.ex найдём представление UrlJSON. Давайте откроем его:
defmodule HelloWeb.UrlJSON do
alias Hello.Urls.Url
@doc """
Renders a list of urls.
"""
def index(%{urls: urls}) do
%{data: for(url <- urls, do: data(url))}
end
@doc """
Renders a single url.
"""
def show(%{url: url}) do
%{data: data(url)}
end
defp data(%Url{} = url) do
%{
id: url.id,
link: url.link,
title: url.title
}
end
end
Это представление очень простое. Функция index получает все URL-адреса и преобразует их в список отображений. Эти отображения помещаются в ключ data в корне, точно так же, как мы видели при обращении к приложению через cURL. Иными словами, наше JSON-представление преобразует сложные данные в простые структуры данных Elixir. После того как слой представлений возвращает результат, Phoenix использует библиотеку Jason, чтобы закодировать JSON и отправить ответ клиенту.
Изучив оставшуюся часть контроллера, вы узнаете, что действие show похоже на действие index. Для действий create, update и delete Phoenix использует ещё одну важную возможность — «резервный обработчик действия» (Action fallback).
Чтение данных запроса
Как мы уже видели в разделе Жизненный цикл запроса, все действия контроллера принимают два аргумента: conn и params. Plug автоматически разбирает параметры пути, параметры запроса и тело запроса и помещает их в аргумент params.
Минимальный пример
Рассмотрим минимальную конфигурацию с тремя маршрутами API (lib/hello_web/router.ex):
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :api do
plug :accepts, ["json"]
end
scope "/api", HelloWeb do
pipe_through :api
get "/", HelloController, :show
get "/:name", HelloController, :show
post "/", HelloController, :show
end
end
Контроллер с одним действием (lib/hello_web/controllers/hello_controller.ex):
defmodule HelloWeb.HelloController do
use HelloWeb, :controller
def show(conn, params) do
render(conn, :greet, params)
end
end
и JSON-представление (lib/hello_web/controllers/hello_json.ex):
defmodule HelloWeb.HelloJSON do
def greet(%{"name" => name}) do
%{greeting: "Hello #{name}!"}
end
end
Эта простая конфигурация может обрабатывать все следующие запросы:
$ curl http://localhost:4000/api/world
$ curl http://localhost:4000/api?name=world
$ curl -iX POST http://localhost:4000/api \
-H 'Content-Type: application/json' \
-d '{"name": "world"}'
Совершенно одинаково: Hello world!
Данные запроса объединяются
Поскольку все данные объединяются в одну карту, передача данных из нескольких источников (например, параметра пути и тела JSON или параметра запроса и формы) приведёт к тому, что поля с одинаковыми именами будут перезаписывать друг друга.
Приоритет следующий: Path Parameters > Body (any kind) > Query Parameters
Продолжим предыдущий пример и добавим обработчик ещё одного POST-запроса, принимающий параметр пути:
scope "/api", HelloWeb do
pipe_through :api
get "/", HelloController, :show
get "/:name", HelloController, :show
post "/", HelloController, :show
post "/:name", HelloController, :show # New route
# ^^^^^ New parameter
end
Теперь рассмотрим следующие запросы:
$ curl -iX POST http://localhost:4000/api/name1?name=name3 \
-H 'Content-Type: application/json' \
-d '{"name": "name2"}'
$ curl -iX POST http://localhost:4000/api?name=name3 \
-H 'Content-Type: application/json' \
-d '{"name": "name2"}'
$ curl -iX POST http://localhost:4000/api?name=name3
Они вернут соответственно Hello name1! (параметр пути), Hello name2! (тело запроса) и Hello name3! (параметр запроса). При необходимости к этим параметрам можно обращаться по отдельности через conn.path_params, conn.body_params и conn.query_params соответственно.
Резервный обработчик действия
Резервный обработчик действия позволяет централизовать обработку ошибок в plug-модулях, которые вызываются, если действие контроллера не возвращает структуру %Plug.Conn{}. Эти plug-модули получают как conn, изначально переданное действию контроллера, так и возвращаемое значение действия.
Предположим, у нас есть действие show, которое использует with, чтобы получить запись блога, а затем проверить, разрешено ли текущему пользователю просматривать её. В этом примере можно ожидать, что fetch_post/1 вернёт {:error, :not_found}, если запись не найдена, а authorize_user/3 вернёт {:error, :unauthorized}, если у пользователя нет прав. Для надлежащей обработки таких ошибок можно использовать представления ErrorHTML и ErrorJSON, которые Phoenix создаёт для каждого нового приложения:
defmodule HelloWeb.MyController do
use Phoenix.Controller
def show(conn, %{"id" => id}, current_user) do
with {:ok, post} <- fetch_post(id),
:ok <- authorize_user(current_user, :view, post) do
render(conn, :show, post: post)
else
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> put_view(html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON)
|> render(:"404")
{:error, :unauthorized} ->
conn
|> put_status(403)
|> put_view(html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON)
|> render(:"403")
end
end
end
Теперь представьте, что похожую логику нужно реализовать для каждого контроллера и действия, обрабатываемых API. Это привело бы к большому количеству повторяющегося кода.
Вместо этого можно определить plug-модуль, который умеет обрабатывать именно эти случаи ошибок. Поскольку контроллеры являются plug-модулями, определим наш plug как контроллер:
defmodule HelloWeb.MyFallbackController do
use Phoenix.Controller
def call(conn, {:error, :not_found}) do
conn
|> put_status(:not_found)
|> put_view(json: HelloWeb.ErrorJSON)
|> render(:"404")
end
def call(conn, {:error, :unauthorized}) do
conn
|> put_status(403)
|> put_view(json: HelloWeb.ErrorJSON)
|> render(:"403")
end
end
Затем можно указать новый контроллер в качестве action_fallback и просто удалить блок else из with:
defmodule HelloWeb.MyController do
use Phoenix.Controller
action_fallback HelloWeb.MyFallbackController
def show(conn, %{"id" => id}, current_user) do
with {:ok, post} <- fetch_post(id),
:ok <- authorize_user(current_user, :view, post) do
render(conn, :show, post: post)
end
end
end
Если условия with не совпадут, HelloWeb.MyFallbackController получит исходный conn, а также результат действия и сформирует соответствующий ответ.
FallbackController и ChangesetJSON
Теперь, зная это, рассмотрим FallbackController (lib/hello_web/controllers/fallback_controller.ex), созданный mix phx.gen.json. В частности, он обрабатывает один вариант (второй сгенерирован в качестве примера):
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> put_view(json: HelloWeb.ChangesetJSON)
|> render(:error, changeset: changeset)
end
Этот вариант предназначен для обработки возвращаемых типов {:error, changeset} из контекста HelloWeb.Urls и преобразования их в отображаемые ошибки с помощью представления ChangesetJSON. Давайте откроем lib/hello_web/controllers/changeset_json.ex, чтобы узнать больше:
defmodule HelloWeb.ChangesetJSON do
@doc """
Renders changeset errors.
"""
def error(%{changeset: changeset}) do
# When encoded, the changeset returns its errors
# as a JSON object. So we just pass it forward.
%{errors: Ecto.Changeset.traverse_errors(changeset, &translate_error/1)}
end
end
Как видим, ошибки преобразуются в структуру данных, которая будет отображена в формате JSON. Changeset — это структура данных, отвечающая за приведение типов и проверку данных. В нашем примере она определена в Hello.Urls.Url.changeset/1. Давайте откроем lib/hello/urls/url.ex и посмотрим на её определение:
@doc false
def changeset(url, attrs) do
url
|> cast(attrs, [:link, :title])
|> validate_required([:link, :title])
end
Как видите, changeset требует указать и ссылку, и заголовок. Поэтому можно попробовать отправить URL без ссылки и заголовка и посмотреть, как ответит API:
$ curl -iX POST http://localhost:4000/api/urls \
-H 'Content-Type: application/json' \
-d '{"url": {}}'
{"errors": {"link": ["can't be blank"], "title": ["can't be blank"]}}
Попробуйте изменить функцию changeset и посмотреть, как поведёт себя API.
Приложения только для API
Если вы хотите создать приложение Phoenix исключительно для API, при вызове mix phx.new можно передать несколько параметров. Давайте посмотрим, какие флаги --no-* нужно использовать, чтобы не создавать ненужные для REST API части приложения Phoenix.
Выполните в терминале:
$ mix help phx.new
В выводе должно быть следующее:
• --no-assets - equivalent to --no-esbuild and --no-tailwind
• --no-dashboard - do not include Phoenix.LiveDashboard
• --no-ecto - do not generate Ecto files
• --no-esbuild - do not include esbuild dependencies and
assets. We do not recommend setting this option, unless for API
only applications, as doing so requires you to manually add and
track JavaScript dependencies
• --no-gettext - do not generate gettext files
• --no-html - do not generate HTML views
• --no-live - comment out LiveView socket setup in your Endpoint
and assets/js/app.js. Automatically disabled if --no-html is given
• --no-mailer - do not generate Swoosh mailer files
• --no-tailwind - do not include tailwind dependencies and
assets. The generated markup will still include Tailwind CSS
classes, those are left-in as reference for the subsequent
styling of your layout and components
При создании приложения Phoenix для API очевидно следует использовать --no-html, чтобы не включать ненужную инфраструктуру для HTML. Также можно передать --no-assets, если вам не нужны средства управления ресурсами, --no-gettext, если вы не используете интернационализацию, и так далее.
Итак, чтобы создать простое API под названием Hello без интерфейса, подключения к базе данных, интернационализации и почтовой службы, можно передать следующие флаги:
$ mix phx.new hello --no-assets --no-dashboard --no-ecto --no-gettext --no-html --no-mailer
По умолчанию при этом всё ещё включаются модули Telemetry, PubSub и DNS Cluster. При необходимости их можно удалить вручную.
Также учтите, что ничто не мешает создать серверную часть, одновременно поддерживающую REST API и веб-приложение (HTML, ресурсы, интернационализацию и сокеты).
© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/json_and_apis.html