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 指南和 Contexts 指南以获取更多信息。 我们还有一个专门用于测试的部分。
最后,生成器要求我们将 /url 资源添加到 lib/hello_web/router.ex 中的 :api scope 内:
scope "/api", HelloWeb do
pipe_through :api
resources "/urls", UrlController, except: [:new, :edit]
end
API scope 使用 :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
或者获取某一个 link :
curl -i http://localhost:4000/api/urls/1
然后来更新一个 link :
curl -iX PUT http://localhost:4000/api/urls/2 \
-H 'Content-Type: application/json' \
-d '{"url": {"title":"Elixir Programming Language"}}'
应该会得到一个 200 的响应,以及更新的 link 作为 body
然后是删除:
curl -iX DELETE http://localhost:4000/api/urls/2 \
-H 'Content-Type: application/json'
应该会得到一个 204 的响应,表示成功删除。
构建 JSON
为了理解怎样构建 JSON,我们从 lib/hello_web/controllers/url_controller.ex 下的 UrlController 中的 index action 开始:
def index(conn, _params) do
urls = Urls.list_urls()
render(conn, :index, urls: urls)
end
正如我们所见,这与 Phoenix 呈现 HTML 模板的方式没有任何不同。 我们调用 render/3,传递 conn、我们希望我们的视图呈现的模板 (:index) 以及我们希望提供给我们的视图的数据。
Phoenix 通常对每种渲染格式使用一个 view 。 渲染 HTML 时,我们会使用 HelloHTML。 现在我们正在渲染 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
这个 view 很简单。 index 函数接收所有 URL,并将它们转换为对象列表。 这些对象位于根对象的 data 内,正如我们从 cURL 与我们的应用程序交互时所看到的一样。 换句话说,我们的 JSON view 将我们的复杂数据转换为简单的 Elixir 数据结构。 一旦我们的视图层返回,Phoenix 使用 Jason 库对 JSON 进行编码并将响应发送给客户端。
如果您探索其余的控制器,您将了解到 show 操作与 index 类似。 对于 create、update 和 delete 操作,Phoenix 使用另一个重要功能,称为“操作回退(Action fallback)”。
操作回退
操作回退允许我们将错误处理代码集中在 plugs 中,当控制器操作无法返回 %Plug.Conn{} 结构时调用这些代码。 这些 plugs 接收最初传递给控制器 action 的 conn 以及 action 的返回值。
假设我们有一个 show action ,它使用 with 来获取博客文章,然后授权当前用户查看该博客文章。 在此示例中,我们可能期望 fetch_post/1 在找不到帖子时返回 {:error, :not_found} ,而 authorize_user/3 在用户未经授权时可能返回 {:error, :unauthorized} 。 我们可以使用 Phoenix 为每个新应用程序生成的 ErrorHTML 和 ErrorJSON 视图来相应地处理这些错误路径:
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 处理的每个控制器和操作实现类似的逻辑。 这将导致大量重复。
相反,我们可以定义一个模块插件,它知道如何专门处理这些错误情况。 由于 controller 是模块 plug ,让我们以 controller 的方式定义一个 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 并简单地从 with 中删除 else 块:
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 and ChangesetJSON
有了这些知识,我们就可以探索由 mix phx.gen.json 生成的 FallbackController (lib/hello_web/controllers/fallback_controller.ex)。 特别是,它处理一个子句(另一个作为示例生成):
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
conn
|> put_status(:unprocessable_entity)
|> put_view(json: HelloWeb.ChangesetJSON)
|> render("error.json", changeset: changeset)
end
此子句的目标是处理来自 HelloWeb.Urls 上下文的 {:error, changeset} 返回类型,并通过 ChangesetJSON view 将它们渲染为错误视图。 让我们打开 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, &HelloWeb.CoreComponents.translate_error/1)}
end
end
如我们所见,它将错误信息转换为一种数据结构,渲染成 JSON。 变更集是负责转换和验证数据的数据结构。 对于我们的示例,它在 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
如您所见,变更集需要同时提供链接和标题。 这意味着我们可以尝试发布一个没有链接和标题的 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"]}}
可以随意修改变更集的功能,然后查看 API 的行为方式。
API-only applications
如果你想专门为 API 生成 Phoenix 应用程序,你可以在调用 mix phx.new 时传递几个选项。 让我们检查一下我们需要使用哪些 --no-* 标志来避免我们的 Phoenix 应用程序生成 REST API 不需要的脚手架。
在命令行运行:
mix help phx.new
应该会有如下输出:
• --no-assets - do not generate the assets folder
• --no-ecto - do not generate Ecto files
• --no-html - do not generate HTML views
• --no-gettext - do not generate gettext files
• --no-dashboard - do not include Phoenix.LiveDashboard
• --no-mailer - do not generate Swoosh mailer files
--no-html 是我们在为 API 创建任何 Phoenix 应用程序时想要使用的显而易见的一个,以便省去所有不必要的 HTML 脚手架。 如果您不想要任何 assets 管理,您也可以传递 --no-assets,如果您不支持国际化,则传递 --no-gettext,等等。
还要记住,没有什么能阻止您拥有同时支持 REST API 和 Web 应用程序(HTML、资产、国际化和套接字)的后端。