├── 00-prepare.md ├── 01-create-project.md ├── 02-explore-phoenix.md ├── 03-project-menu.md ├── 04-user-register ├── 00-prepare.md ├── 01-username-required.md ├── 02-username-unique.md ├── 03-username-format.md ├── 04-username-length.md ├── 05-username-exclude.md ├── 06-email-rules.md ├── 07-password-rules.md ├── 08-password-storage.md └── 09-optimize-ui.md ├── 05-session ├── 01-login.md ├── 02-auto-login-user.md ├── 03-logout.md └── 04-login-logout-buttons.md ├── 06-restrict-access.md ├── 07-recipe ├── 01-gen-html.md ├── 02-recipe-scheme.md ├── 03-recipe-controller.md ├── 04-recipe-view.md └── 05-recipe-tv-url.md ├── LICENSE ├── img ├── 02-help-page-screenshot.png ├── 02-mix-phoenix.gen.html.png ├── 02-phoenixframework-page-screenshot.png ├── 04-iex-shell.png ├── 04-password-input.png ├── 04-user-list.png ├── 04-username-has-error.png ├── 04-users-blank-username.png ├── 04-users-new-page.png ├── 05-login-user-http.png ├── 07-generate-recipe.png └── alipay-qr.png └── readme.md /00-prepare.md: -------------------------------------------------------------------------------- 1 | # Phoenix Framework 开发准备工作 2 | 3 | 如果你的英文阅读能力不错,建议直接查阅 [Phoenix Framework 官方的安装指南](https://hexdocs.pm/phoenix/installation.html)。 4 | 5 | 以下是我写的简略说明,请确保你的**网络畅通**。 6 | 7 | ## 安装 Elixir (>= 1.4) 8 | 9 | Phoenix Framework 是用 [Elixir 语言](http://elixir-lang.org/)开发的,我们的 Phoenix 项目同样使用 Elixir,因此我们需要在开发机器上安装 Elixir。请参照 [Elixir 官网的安装文档](http://elixir-lang.org/install.html)。 10 | 11 | 安装完 Elixir 后,打开命令行窗口,输入: 12 | 13 | ```bash 14 | $ elixir -v 15 | ``` 16 | 即可查看当前安装的 Elixir 版本。 17 | 18 | ## 安装 Erlang (>= 18) 19 | 20 | 大部分时候,我们可以跳过这一步。因为安装 Elixir 时,通常会一并安装 Erlang。 21 | 22 | 两种例外情况: 23 | 24 | 1. 开发机器上已安装的 Erlang 版本太低 - 不到 18.0,而 Elixir 对 Erlang 的版本要求是 18 以上 25 | 2. 安装 Elixir 时,未能一并安装 Erlang 26 | 27 | 此时你可以按照 Elixir 官网上提供的[说明](http://elixir-lang.org/install.html#installing-erlang)来安装 Erlang。 28 | 29 | 安装完 Erlang 后,我们可以在命令行窗口输入: 30 | 31 | ```bash 32 | erl -eval 'erlang:display(erlang:system_info(otp_release)), halt().' -noshell 33 | ``` 34 | 就能看到当前安装好的 Erlang 版本。 35 | 36 | ## 安装 Hex 37 | 38 | [Hex](https://hex.pm/) 是 Elixir 的包管理器,我们将用它来管理 Phoenix 项目的依赖。 39 | 40 | 安装方法如下: 41 | 42 | ```bash 43 | $ mix local.hex --force 44 | ``` 45 | 这里我们用到 [Mix](http://elixir-lang.org/docs/stable/mix/Mix.html)。Mix 是 Elixir 的构建工具,提供许多便捷功能,比如项目创建、编译、测试等等。我们将在 Phoenix 开发中大量运用。 46 | 47 | ## 安装 Phoenix 48 | 49 | ```bash 50 | $ mix archive.install https://github.com/phoenixframework/archives/raw/master/phx_new.ez 51 | ``` 52 | 53 | ## 安装 Node.js(>=5.0.0) 54 | 55 | 如果你用 Phoenix 只是开发 API 接口,不涉及 JavaScript、CSS、图片等静态资源,则可以跳过 Node.js 的安装。否则请参照[ Node.js 官方文档安装](https://nodejs.org/en/download/) Node.js,这是因为 Phoenix 默认使用 [brunch.io](http://brunch.io/) 来管理静态资源,而 brunch 是基于 Node.js 开发的。 56 | 57 | 安装完 Node.js 后,在命令行下输入: 58 | 59 | ```bash 60 | node --version 61 | ``` 62 | 可以确认它的版本号。 63 | 64 | ## PostgreSQL 65 | 66 | Phoenix 默认使用 PostgreSQL 数据库,因此,也请[根据 PostgreSQL 文档](https://wiki.postgresql.org/wiki/Detailed_installation_guides)安装好它。 67 | 68 | 如果你更熟悉 MySQL,或 MongoDB,Phoenix 也有提供相应适配器。 69 | 70 | ## inotify-tools 71 | 72 | 如果你是 Linux 用户,你还需要安装 [inotify-tools](https://github.com/rvoicilas/inotify-tools/wiki),Phoenix 实时刷新功能需要用到它。mac 或 windows 用户则不必关心。 73 | 74 | 好了,一切准备就绪,接下来,我们将[创建一个 Phoenix 项目](01-create-project.md)。 -------------------------------------------------------------------------------- /01-create-project.md: -------------------------------------------------------------------------------- 1 | # 创建 Phoenix 项目 2 | 3 | 恭喜你完成了第一章的准备工作! 4 | 5 | 前面我们提到过,Mix 是 Elixir 提供的构建工具。这一章里,我们就用它来创建一个 Phoenix 项目: 6 | 7 | ```bash 8 | $ mix phx.new menu --database mysql 9 | ``` 10 | 这里,`mix phx.new` 表示创建一个 Phoenix 项目,`menu` 指示新项目的路径,即当前目录下的 `menu` 目录。如果目录已存在,命令会提示我们是否覆盖目录下的内容,否则会新建 `menu` 目录。`--database mysql` 则表示该项目的数据库类型是 MySQL,而不是默认的 PostgreSQL。 11 | 12 | `mix phx.new` 命令在执行过程中,会提示我们: 13 | 14 | ```bash 15 | Fetch and install dependencies? [Yn] 16 | ``` 17 | 18 | 是否安装依赖?默认是 **Y**,回车即可。如果你输入 **n**,后期启动 Phoenix 服务时还会提示一遍。 19 | 20 | 安装完依赖,我们会看到如下说明: 21 | 22 | ```bash 23 | We are all set! Go into your application by running: 24 | 25 | $ cd menu 26 | 27 | Then configure your database in config/dev.exs and run: 28 | 29 | $ mix ecto.create 30 | 31 | Start your Phoenix app with: 32 | 33 | $ mix phx.server 34 | 35 | You can also run your app inside IEx (Interactive Elixir) as: 36 | 37 | $ iex -S mix phx.server 38 | ``` 39 | 首先,我们需要配置数据库。 40 | 41 | 打开 `config/dev.exs` 文件,可以看到数据库相关的配置内容: 42 | 43 | ```elixir 44 | # Configure your database 45 | config :menu, Menu.Repo, 46 | adapter: Ecto.Adapters.MySQL, 47 | username: "root", 48 | password: "", 49 | database: "menu_dev", 50 | hostname: "localhost", 51 | pool_size: 10 52 | ``` 53 | 如果你的 MySQL 数据库`用户名/密码`不是 `root/` 组合,请修改后再执行 `mix ecto.create`: 54 | 55 | ```sh 56 | ➜ menu mix ecto.create 57 | Compiling 13 files (.ex) 58 | Generated menu app 59 | The database for Menu.Repo has been created 60 | ``` 61 | 数据库已成功创建。 -------------------------------------------------------------------------------- /02-explore-phoenix.md: -------------------------------------------------------------------------------- 1 | # Phoenix 初体验 2 | 3 | 在[前一章](01-create-project.md),我们创建了 `Menu` 项目。现在,让我们进入项目的根目录,启动服务器: 4 | 5 | ```bash 6 | $ cd menu 7 | $ mix phx.server 8 | ``` 9 | 打开浏览器,访问 [http://localhost:4000](http://localhost:4000) 网址,我们会看到如下截图所示的内容: 10 | 11 | ![PhoenixFramework Page](img/02-phoenixframework-page-screenshot.png) 12 | 13 | 那么,从输入网址到返回页面期间,都发生了什么?让我们来简单了解一下。 14 | 15 | 1. 我们在浏览器访问 `http://localhost:4000` 网址 16 | 2. Phoenix 在服务端收到 HTTP 请求,它检查 `menu` 目录下的 `lib/menu_web/router.ex` 文件,定位到如下内容: 17 | 18 | ```elixir 19 | scope "/", MenuWeb do 20 | pipe_through :browser # Use the default browser stack 21 | 22 | get "/", PageController, :index 23 | end 24 | ``` 25 | 我们看到,`router.ex` 文件里已经设定好这么一条规则:用户 `get` 路径 `/` 时,`PageController` 模块中的 `index` 动作将接手处理请求。 26 | 3. 按图索骥,我们来看看 `lib/menu_web/controllers/page_controller.ex` 文件内容: 27 | 28 | ```elixir 29 | defmodule MenuWeb.PageController do 30 | use MenuWeb, :controller 31 | 32 | def index(conn, _params) do 33 | render conn, "index.html" 34 | end 35 | end 36 | ``` 37 | 目前 `page_controller.ex` 文件中只定义了 `index` 一个动作 - 正是 `index` 动作中的 `render conn, "index.html"` 渲染了我们上面截图中的内容。 38 | 39 | 那么,我是怎么知道 `PageController` 定义在 `lib/menu_web/controllers/page_controller.ex` 文件的?你可能会这样问。这里,我们要了解 Phoenix 的一个约定:所有的控制器都定义在 `controllers` 目录下,并且文件名与模块名的命名有对应关系:文件名是 `a_b.ex` 时,对应的模块名就是 `AB`。 40 | 4. 最后我们就来到 `tempates/page/index.html` 文件。 41 | 42 | 很简单是不是? 43 | 44 | 让我们依葫芦画瓢,添加一个 `/help` 试试。 45 | 46 | 不过,在动手前,且让我们先在当前目录下初始化 git,将 `mix phx.new` 生成的所有文件保存起来: 47 | 48 | ```sh 49 | $ git init 50 | $ git add . 51 | $ git commit -m 'init` 52 | ``` 53 | 这样,我们后面想要清理用不到的文件时,就非常容易。 54 | 55 | ## 添加帮助页面 56 | 57 | 1. 首先在 `router.ex` 文件中添加路由: 58 | 59 | ```elixir 60 | get "/help", HelpController, :index 61 | ``` 62 | 2. 然后在 `controllers` 目录下新建一个 `help_controller.ex` 文件,添加如下内容: 63 | 64 | ```elixir 65 | defmodule MenuWeb.HelpController do 66 | use MenuWeb, :controller 67 | 68 | def index(conn, _params) do 69 | render conn, "index.html" 70 | end 71 | end 72 | ``` 73 | 3. 此时的 `http://localhost:4000/help` 网址显示: 74 | 75 | ```html 76 | UndefinedFunctionError at GET /help 77 | function MenuWeb.HelpView.render/2 is undefined (module MenuWeb.HelpView is not available) 78 | ``` 79 | 报错。错误显示,我们还没有定义 `MenuWeb.HelpView` 视图模块。 80 | 81 | 4. 在 `views` 目录下新建 `help_view.ex` 文件,内容参照 `views/page_view.ex` 文件,如下: 82 | 83 | ```elixir 84 | defmodule MenuWeb.HelpView do 85 | use MenuWeb, :view 86 | end 87 | ``` 88 | 5. 再看看 `http://localhost:4000/help` 网址: 89 | 90 | ```html 91 | Phoenix.Template.UndefinedError at GET /help 92 | Could not render "index.html" for MenuWeb.HelpView, please define a matching clause for render/2 or define a template at "lib/menu_web/templates/help". No templates were compiled for this module. 93 | ``` 94 | 还是报错。提示我们要在 `lib/menu_web/templates/help` 目录下创建一个模板文件。 95 | 96 | 6. 在 `lib/menu_web/templates/help` 目录下新建一个 `index.html.eex` 文件,添加如下内容: 97 | 98 | ```html 99 |

这是帮助内容

100 | ``` 101 | 7. 再看 `http://localhost:4000/help` 网址: 102 | 103 | ![帮助页面截图](img/02-help-page-screenshot.png) 104 | 105 | 这一次,页面终于显示正常。 106 | 107 | 从路由,到控制器,到视图,到模板,每一步,一旦出错,Phoenix 都会有完整的提示。所以哪怕我们还不清楚它们是什么,也是可以按照提示,成功添加新页面 - 当然,最好还是要清楚 MVC 是什么,否则再容易也很难。 108 | 109 | 另外,你可能已经发现,Phoenix 能够自动刷新浏览器中打开的页面,不需要我们修改文件后手动刷新页面,非常便利。 110 | 111 | 我们来简单整理下这一章涉及的几个概念: 112 | 113 | 1. [路由](https://hexdocs.pm/phoenix/routing.html)(router)- 决定哪个请求由哪个控制器中的哪个动作来处理 114 | 2. [控制器](https://hexdocs.pm/phoenix/controllers.html)(controller)- 决定怎么处理请求 115 | 3. [视图](https://hexdocs.pm/phoenix/views.html)(view)- 决定渲染哪种模板 116 | 4. [模板](https://hexdocs.pm/phoenix/templates.html)(template)- 决定要展示怎样的内容给用户 117 | 118 | 下一章,我们将对 [Menu 项目做个规划](03-menu-project.md)。 -------------------------------------------------------------------------------- /03-project-menu.md: -------------------------------------------------------------------------------- 1 | # Menu 项目规划 2 | 3 | 在前面章节,我们创建了 Menu 项目。这一章里,我们要对 Menu 项目做一个规划。 4 | 5 | 先来聊聊需求,我为什么会想做这么一个项目。 6 | 7 | 从 Menu 的名称里,你可能已经猜出大半,这会是一个菜单网站 - 但不是哪家酒店、哪家饭馆的菜单,而是**我的菜单**。 8 | 9 | 很长一段时间,我都在为下一顿吃什么而苦恼,正所谓,吾日三省吾身,早饭吃什么,午饭吃什么,晚饭吃什么。 10 | 11 | 后来有一天灵光一闪,想明白了:我把我每顿要吃什么写下来,列出一礼拜的菜单,然后循环使用,不就解决问题了? 12 | 13 | 我们的模块大致划分如下: 14 | 15 | 1. 用户模块 16 | 1. 用户注册 17 | 2. 确认邮箱地址 18 | 3. 密码找回 19 | 2. 会话模块 20 | 1. 登录 21 | 2. 退出登录 22 | 3. 待补充 23 | 24 | 下一章,我们就开始开发[用户注册的功能](04-user-register/00-prepare.md)。 25 | 26 | 但在项目开始前,请记得清理前面章节中添加的 `/help` 的代码。 -------------------------------------------------------------------------------- /04-user-register/00-prepare.md: -------------------------------------------------------------------------------- 1 | # 注册用户 2 | 3 | 在开始敲代码前,我们先确认一下,TvRecipe 项目中,用户有哪些数据需要存储,这些数据要加上什么限制,如果超出限制,要报告什么错误。 4 | 5 | 1. username(用户名) 6 | 7 | 限制|错误提示 8 | ---|--- 9 | 必填|请填写 10 | 不能重复|用户名已被人占用 11 | 只能使用英文字母、数字及下划线|用户名只允许使用英文字母、数字及下划线 12 | 最短 3 位|用户名最短 3 位 13 | 最长 15 位|用户名最长 15 位 14 | 不能是 `admin` 或 `administrator` 这种系统保留的用户名|系统保留,无法注册,请更换 15 | 16 | 2. email(邮箱) 17 | 18 | 限制|错误提示 19 | ---|--- 20 | 必填|请填写 21 | 不能重复|邮箱已被人占用 22 | 邮箱必须包含 `@` 字符|邮箱格式错误 23 | 24 | 3. password(密码) 25 | 26 | 限制|错误提示 27 | ---|--- 28 | 必填|请填写 29 | 密码最短 6 位|密码最短 6 位 30 | 密码不能明文存储在数据库中|- 31 | 32 | 好了,接下来准备写代码。 33 | 34 | ## 样板命令 35 | 36 | 还记得在 [Phoenix 初探](../02-explore-phoenix.md)一章里,我们是如何添加的帮助页面吗? 37 | 38 | 1. 在 `web/router.ex` 文件中添加路由 39 | 2. 添加控制器文件 `help_controller.ex` 40 | 3. 添加视图文件 `help_view.ex` 41 | 4. 添加模板文件 `index.html.eex` 42 | 43 | 但这样的手动添加过程太麻烦,还容易出错,应该有便捷的方法。 44 | 45 | 是的,Phoenix 提供了一系列的 mix 工具包。我们要接触的这个是 [`mix phoenix.gen.html`](https://hexdocs.pm/phoenix/Mix.Tasks.Phoenix.Gen.Html.html)。 46 | 47 | 请在命令行窗口下切换到 `tv_recipe` 目录,然后执行 `mix phoenix.gen.html` 命令: 48 | 49 | ``` 50 | $ cd tv_recipe 51 | $ mix phoenix.gen.html User users username:string:unique email:string:unique password:string 52 | ``` 53 | ![mix phoenix.gen.html 命令](../img/02-mix-phoenix.gen.html.png) 54 | 55 | 执行命令后的输出如下: 56 | 57 | ```bash 58 | * creating web/controllers/user_controller.ex 59 | * creating web/templates/user/edit.html.eex 60 | * creating web/templates/user/form.html.eex 61 | * creating web/templates/user/index.html.eex 62 | * creating web/templates/user/new.html.eex 63 | * creating web/templates/user/show.html.eex 64 | * creating web/views/user_view.ex 65 | * creating test/controllers/user_controller_test.exs 66 | * creating web/models/user.ex 67 | * creating test/models/user_test.exs 68 | * creating priv/repo/migrations/20170123145857_create_user.exs 69 | 70 | Add the resource to your browser scope in web/router.ex: 71 | 72 | resources "/users", UserController 73 | 74 | Remember to update your repository by running migrations: 75 | 76 | $ mix ecto.migrate 77 | ``` 78 | 命令生成的文件很多,我们来看最底下的两段提示: 79 | 80 | 1. 添加 `resources "/users", UserController` 到 `web/router.ex` 文件中 81 | 2. 命令行下执行 `mix ecto.migrate` 82 | 83 | 前几章里,我们在添加帮助页面时,给 `web/router.ex` 文件添加过一行代码: 84 | 85 | ```elixir 86 | get "/help", HelpController, :index 87 | ``` 88 | 89 | 这里,`resources "/users", UserController` 起的是类似作用。我们可以不厌其烦地写成如下: 90 | 91 | ```elixir 92 | get "/users", UserController, :index 93 | get "/users/:id/edit", UserController, :edit 94 | get "/users/new", UserController, :new 95 | get "/users/:id", UserController, :show 96 | post "/users", UserController, :create 97 | patch "/users/:id", UserController, :update 98 | put "/users/:id", UserController, :update 99 | delete "/users/:id", UserController, :delete 100 | ``` 101 | 可是,谁不会厌烦呢?所以 Phoenix 提供了 `resources` 这一便捷方法。 102 | 103 | 再来说说 `mix ecto.migrate`。 104 | 105 | 目前为止,我们还没有真正操作过数据库。可我们的用户数据必须存储在数据库中,我们难道要自己手动执行 SQL 语句来创建用户表格? 106 | 107 | 不不不,我们只要运行 `mix ecto.migrate`,一切便都妥当了: 108 | 109 | ```bash 110 | $ mix ecto.migrate 111 | Compiling 15 files (.ex) 112 | Generated tv_recipe app 113 | 114 | 11:08:12.056 [info] == Running TvRecipe.Repo.Migrations.CreateUser.change/0 forward 115 | 116 | 11:08:12.057 [info] create table users 117 | 118 | 11:08:12.065 [info] create index users_username_index 119 | 120 | 11:08:12.066 [info] create index users_email_index 121 | 122 | 11:08:12.067 [info] == Migrated in 0.0s 123 | ``` 124 | 125 | 操作完上述两步后,因为某些编辑器可能导致的代码重载问题,你需要重启 Phoenix 服务器 - 按两次 Ctrl-C,然后重新执行 `mix phoenix.server`。 126 | 127 | 之后在浏览器中打开网址 `http://localhost:4000/users/new`: 128 | 129 | ![创建用户页面截图](../img/04-users-new-page.png) 130 | 131 | 有了。是不是很惊讶?我们用 `mix phoenix.gen.html` 命令生成的样板,功能已经很完善:增删改查功能全都有了。我们需要的,只是在样板基础上做点修改。 132 | 133 | [接下来](01-username-required.md)几章,我们将一步步完成本章开头列出的限制条件。 134 | 135 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /04-user-register/01-username-required.md: -------------------------------------------------------------------------------- 1 | # username 必填 2 | 3 | [上一章](00-prepare.md)里,我们用 `mix phoenix.gen.html` 命令创建出完整用户界面,并且具备增加、删除、更改、查询用户的功能。 4 | 5 | 这一章,我们将实现 `username` 的第一个规则:`username` 必填,如果未填写,提示用户`请填写`。 6 | 7 | 先来看看,在 `http://localhost:4000/users/new` 页面上,提交空白用户名的话,我们会看到什么? 8 | 9 | 页面会提示我们,`can't be blank`。 10 | 11 | 很好,虽然不知道怎么回事,但**必填**的限制已经有了,那如何将它改成`请填写`呢? 12 | 13 | 打开 `web/models/user.ex` 文件,其中有一行: 14 | 15 | ```elixir 16 | |> validate_required([:username, :email, :password]) 17 | ``` 18 | 正是 [`validate_required`](https://hexdocs.pm/ecto/Ecto.Changeset.html#validate_required/3) 明确了 `username` 为必填。从文档里我们看到,`validate_required` 还接收一个可选的 `message` 参数,用于自定义错误消息。 19 | 20 | 让我们加上试试: 21 | 22 | ```elixir 23 | diff --git a/web/models/user.ex b/web/models/user.ex 24 | index b7713a0..87ce321 100644 25 | --- a/web/models/user.ex 26 | +++ b/web/models/user.ex 27 | @@ -15,7 +15,7 @@ defmodule TvRecipe.User do 28 | def changeset(struct, params \\ %{}) do 29 | struct 30 | |> cast(params, [:username, :email, :password]) 31 | - |> validate_required([:username, :email, :password]) 32 | + |> validate_required([:username, :email, :password], message: "请填写") 33 | |> unique_constraint(:username) 34 | |> unique_constraint(:email) 35 | end 36 | ``` 37 | 38 | 打开网址,提交空白用户名,页面上已经显示“请填写”了: 39 | 40 | ![show error when user submit blank username](../img/04-users-blank-username.png) 41 | 42 | 很好,但请注意,我们这是**人肉测试**。 43 | 44 | 又或者,我们可以用 Phoenix 生成的测试文件来验证。 45 | 46 | 打开 `test/models/user_test.exs` 文件,默认内容如下: 47 | 48 | ```elixir 49 | defmodule TvRecipe.UserTest do 50 | use TvRecipe.ModelCase 51 | 52 | alias TvRecipe.User 53 | 54 | @valid_attrs %{email: "some content", password: "some content", username: "some content"} 55 | @invalid_attrs %{} 56 | 57 | test "changeset with valid attributes" do 58 | changeset = User.changeset(%User{}, @valid_attrs) 59 | assert changeset.valid? 60 | end 61 | 62 | test "changeset with invalid attributes" do 63 | changeset = User.changeset(%User{}, @invalid_attrs) 64 | refute changeset.valid? 65 | end 66 | end 67 | ``` 68 | 文件中有两个变量,`@valid_attrs` 表示有效的 `User` 属性,`@invalid_attrs` 表示无效的 `User` 属性,我们按本章开头拟定的规则修改 `@valid_attrs`: 69 | 70 | ```elixir 71 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 72 | index 1d5494f..7c73207 100644 73 | --- a/test/models/user_test.exs 74 | +++ b/test/models/user_test.exs 75 | @@ -3,7 +3,7 @@ defmodule TvRecipe.UserTest do 76 | 77 | alias TvRecipe.User 78 | 79 | - @valid_attrs %{email: "some content", password: "some content", username: "some content"} 80 | + @valid_attrs %{email: "chenxsan@gmail.com", password: "some content", username: "chenxsan"} 81 | @invalid_attrs %{} 82 | 83 | test "changeset with valid attributes" do 84 | ``` 85 | 86 | 接着,在 `user_test.exs` 文件中添加一个新测试: 87 | 88 | ```elixir 89 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 90 | index 7c73207..4c174ab 100644 91 | --- a/test/models/user_test.exs 92 | +++ b/test/models/user_test.exs 93 | @@ -15,4 +15,9 @@ defmodule TvRecipe.UserTest do 94 | changeset = User.changeset(%User{}, @invalid_attrs) 95 | refute changeset.valid? 96 | end 97 | + 98 | + test "username should not be blank" do 99 | + attrs = %{@valid_attrs | username: ""} 100 | + assert {:username, "请填写"} in errors_on(%User{}, attrs) 101 | + end 102 | end 103 | ``` 104 | 105 | 这里,`%{@valid_attrs | username: ""}` 是 Elixir 更新映射(Map)的一个方法。 106 | 107 | 至于 `errors_on` 函数,它定义在 `tv_recipe/test/support/model_case.ex` 文件中: 108 | 109 | ```elixir 110 | def errors_on(struct, data) do 111 | struct.__struct__.changeset(struct, data) 112 | |> Ecto.Changeset.traverse_errors(&TvRecipe.ErrorHelpers.translate_error/1) 113 | |> Enum.flat_map(fn {key, errors} -> for msg <- errors, do: {key, msg} end) 114 | end 115 | ``` 116 | 它检查给定数据中的错误消息,并返回给我们。 117 | 118 | 现在在命令行下运行: 119 | 120 | ```bash 121 | $ mix test test/models/user_test.exs 122 | ``` 123 | 结果如下: 124 | 125 | ```bash 126 | ... 127 | 128 | Finished in 0.07 seconds 129 | 3 tests, 0 failures 130 | ``` 131 | 测试通过。现在我们可以放心地认为,用户提交空白 `username` 时,Phoenix 一定会返回“请填写”的错误消息。 132 | 133 | 下一章,我们将[验证 username 的唯一性](02-username-unique.md)。 134 | 135 | ## 为什么要写测试? 136 | 137 | 可能很多人都抱有这个疑问。测试增加了我们的工作量,而它的作用又不那么明显。更何况,这还只是一个入门教程。 138 | 139 | 我想谈几点个人感受: 140 | 141 | 1. 我不喜欢拿自己当人肉测试机。 142 | 2. 代码的修改是必然发生的,而我们在修改时无法保证周全,此时测试即任务清单,它帮我们指出,哪些地方的代码需统一修改,这样我们才能保证代码的质量。 143 | 3. 在团队协作中,你很难保证别人的代码不会破坏到自己的那部分。比如一个开源项目,有人在 github 上提了 pull request,你如果有测试代码,马上就能知道,这个 pull request 是否会破坏其它功能,如果你没有测试代码,好了,你得一行一行验证了 - 这种成本无论是对维护者还是贡献者来说,都是极大的浪费。 144 | 145 | 而况在 Phoenix 框架下,测试非常容易写。 146 | -------------------------------------------------------------------------------- /04-user-register/02-username-unique.md: -------------------------------------------------------------------------------- 1 | # username 已被人占用 2 | 3 | 如果你已完成[上一章](01-username-required.md),你可能已经猜到,这章的规则要怎么写,不过在那之前,还是让我们先写个测试: 4 | 5 | ```elixir 6 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 7 | index 4c174ab..47df0c7 100644 8 | --- a/test/models/user_test.exs 9 | +++ b/test/models/user_test.exs 10 | @@ -20,4 +20,13 @@ defmodule TvRecipe.UserTest do 11 | attrs = %{@valid_attrs | username: ""} 12 | assert {:username, "请填写"} in errors_on(%User{}, attrs) 13 | end 14 | + 15 | + test "username should be unique" do 16 | + # 在测试数据库中插入新用户 17 | + user_changeset = User.changeset(%User{}, @valid_attrs) 18 | + TvRecipe.Repo.insert! user_changeset 19 | + 20 | + # 尝试插入同名用户,应报告错误 21 | + assert {:error, changeset} = TvRecipe.Repo.insert(User.changeset(%User{}, %{@valid_attrs | email: "chenxsan+1@gmail.com"})) 22 | + end 23 | end 24 | ``` 25 | 此时运行 `mix test test/models/user_test.exs`,我们的测试会全部通过。这是因为,我们在执行 `mix phoenix.gen.html` 命令时,指定了 `unique` 给 `username` 字段,这样生成的 `User` 结构里,我们已经有了唯一性的限定规则,如下所示: 26 | 27 | ```elixir 28 | def changeset(struct, params \\ %{}) do 29 | struct 30 | |> cast(params, [:username, :email, :password]) 31 | |> validate_required([:username, :email, :password], message: "请填写") 32 | |> unique_constraint(:username) 33 | |> unique_constraint(:email) 34 | end 35 | ``` 36 | 但上面的测试里,我们只知道插入同名用户时,Phoenix 会返回错误,至于错误是什么,我们还没有检查。 37 | 38 | 还记得前一章里用于检查给定数据的错误的 `errors_on` 函数么? 39 | 40 | ```elixir 41 | def errors_on(struct, data) do 42 | struct.__struct__.changeset(struct, data) 43 | |> Ecto.Changeset.traverse_errors(&TvRecipe.ErrorHelpers.translate_error/1) 44 | |> Enum.flat_map(fn {key, errors} -> for msg <- errors, do: {key, msg} end) 45 | end 46 | ``` 47 | 但很可惜,它接收的是一个结构(struct)与映射。而我们现在手头上只有一个 `TvRecipe.Repo.insert(user_changeset)` 返回的 `changset` 可用。 48 | 49 | 我们要在 `tv_recipe/test/support/model_case.ex` 文件中再定义一个 `errors_on` 函数,这一回,它接收一个 `changeset` 参数: 50 | 51 | ```elixir 52 | diff --git a/test/support/model_case.ex b/test/support/model_case.ex 53 | index 2b9cb59..85006b5 100644 54 | --- a/test/support/model_case.ex 55 | +++ b/test/support/model_case.ex 56 | @@ -62,4 +62,10 @@ defmodule TvRecipe.ModelCase do 57 | |> Ecto.Changeset.traverse_errors(&TvRecipe.ErrorHelpers.translate_error/1) 58 | |> Enum.flat_map(fn {key, errors} -> for msg <- errors, do: {key, msg} end) 59 | end 60 | + 61 | + def errors_on(changeset) do 62 | + changeset 63 | + |> Ecto.Changeset.traverse_errors(&TvRecipe.ErrorHelpers.translate_error/1) 64 | + |> Enum.flat_map(fn {key, errors} -> for msg <- errors, do: {key, msg} end) 65 | + end 66 | end 67 | ``` 68 | 是否很吃惊?要知道,如果是在 JavaScript 里写两个同名函数,后一个函数会覆盖前一个的定义,而 Elixir 下,我们可以定义多个同名函数,它们能处理不同的状况,而又互不干扰。 69 | 70 | 我们来完善下我们上面的测试代码: 71 | 72 | ```elixir 73 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 74 | index 47df0c7..9748671 100644 75 | --- a/test/models/user_test.exs 76 | +++ b/test/models/user_test.exs 77 | @@ -28,5 +28,8 @@ defmodule TvRecipe.UserTest do 78 | 79 | # 尝试插入同名用户,应报告错误 80 | assert {:error, changeset} = TvRecipe.Repo.insert(user_changeset) 81 | + 82 | + # 错误信息为“用户名已被人占用” 83 | + assert {:username, "用户名已被人占用"} in errors_on(changeset) 84 | end 85 | end 86 | ``` 87 | 88 | 再次运行 `mix test test/models/user_test.exs` 的结果是: 89 | 90 | ```bash 91 | $ mix test test/models/user_test.exs 92 | . 93 | 94 | 1) test username should be unique (TvRecipe.UserTest) 95 | test/models/user_test.exs:24 96 | Assertion with in failed 97 | code: {:username, "用户名已被人占用"} in errors_on(changeset) 98 | left: {:username, "用户名已被人占用"} 99 | right: [username: "has already been taken"] 100 | stacktrace: 101 | test/models/user_test.exs:33: (test) 102 | 103 | .. 104 | 105 | Finished in 0.1 seconds 106 | 4 tests, 1 failure 107 | ``` 108 | 测试不通过。因为"用户名已被人占用"不等于 "has already been taken"。 109 | 110 | 这是当然,我们还未自定义用户名重复时的提示消息。 111 | 112 | 打开 `web/models/user.ex` 文件,做如下修改: 113 | 114 | ```elixir 115 | diff --git a/web/models/user.ex b/web/models/user.ex 116 | index 87ce321..88ad2af 100644 117 | --- a/web/models/user.ex 118 | +++ b/web/models/user.ex 119 | @@ -16,7 +16,7 @@ defmodule TvRecipe.User do 120 | struct 121 | |> cast(params, [:username, :email, :password]) 122 | |> validate_required([:username, :email, :password], message: "请填写") 123 | - |> unique_constraint(:username) 124 | + |> unique_constraint(:username, message: "用户名已被人占用") 125 | |> unique_constraint(:email) 126 | end 127 | end 128 | ``` 129 | 130 | 再跑一次测试,顺利通过。 131 | 132 | 结束这一章了?不不不,还有一点,我们或许遗漏了,就是用户名的大小写。 133 | 134 | ## 大小写敏感 135 | 136 | 我们先写个测试验证一下: 137 | 138 | ```elixir 139 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 140 | index 9748671..44cb21b 100644 141 | --- a/test/models/user_test.exs 142 | +++ b/test/models/user_test.exs 143 | @@ -32,4 +32,13 @@ defmodule TvRecipe.UserTest do 144 | # 错误信息为“用户名已被人占用” 145 | assert {:username, "用户名已被人占用"} in errors_on(changeset) 146 | end 147 | + 148 | + test "username should be case insensitive" do 149 | + user_changeset = User.changeset(%User{}, @valid_attrs) 150 | + TvRecipe.Repo.insert! user_changeset 151 | + 152 | + # 尝试插入大小写不一致的用户名,应报告错误 153 | + another_user_changeset = User.changeset(%User{}, %{@valid_attrs | username: "Chenxsan", email: "chenxsan+1@gmail.com"}) 154 | + assert {:error, changeset} = TvRecipe.Repo.insert(another_user_changeset) 155 | + end 156 | end 157 | ``` 158 | 运行测试的结果是: 159 | 160 | ```bash 161 | $ mix test test/models/user_test.exs 162 | warning: variable "changeset" is unused 163 | test/models/user_test.exs:42 164 | 165 | ... 166 | 167 | 1) test username should be case insensitive (TvRecipe.UserTest) 168 | test/models/user_test.exs:36 169 | match (=) failed 170 | code: {:error, changeset} = TvRecipe.Repo.insert(another_user_changeset) 171 | right: {:ok, 172 | %TvRecipe.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">, 173 | email: "chenxsan+1@gmail.com", id: 36, 174 | inserted_at: ~N[2017-01-24 11:57:43.741097], 175 | password: "some content", 176 | updated_at: ~N[2017-01-24 11:57:43.741109], username: "Chenxsan"}} 177 | stacktrace: 178 | test/models/user_test.exs:42: (test) 179 | 180 | . 181 | 182 | Finished in 0.1 seconds 183 | 5 tests, 1 failure 184 | ``` 185 | 我们的判断错了。无论是 `chenxsan` 还是 `Chenxsan` 的用户名,我们都插入成功,这当然不是我们期望的结果。 186 | 187 | 我们来看看 `unique_constraint` [文档的一段说明](https://hexdocs.pm/ecto/Ecto.Changeset.html#unique_constraint/3-case-sensitivity): 188 | 189 | > Unfortunately, different databases provide different guarantees when it comes to case-sensitiveness. For example, in MySQL, comparisons are case-insensitive by default. In Postgres, users can define case insensitive column by using the :citext type/extension. 190 | 191 | 不同数据库对大小写的处理不一样,比如 MySQL 是大小写不敏感的,而默认情况下,PostgreSQL 字段是大小写敏感的,不过我们可以使用 [citext](https://www.postgresql.org/docs/current/static/citext.html) 扩展类型。 192 | 193 | 如果不用 citext,文档中仍有其它办法: 194 | 195 | > If for some reason your database does not support case insensitive columns, you can explicitly downcase values before inserting/updating them 196 | 197 | 根据提示,我们的 `user.ex` 代码可以做如下修改: 198 | 199 | ```elixir 200 | diff --git a/web/models/user.ex b/web/models/user.ex 201 | index 88ad2af..fc07824 100644 202 | --- a/web/models/user.ex 203 | +++ b/web/models/user.ex 204 | @@ -16,6 +16,7 @@ defmodule TvRecipe.User do 205 | struct 206 | |> cast(params, [:username, :email, :password]) 207 | |> validate_required([:username, :email, :password], message: "请填写") 208 | + |> update_change(:username, &String.downcase/1) 209 | |> unique_constraint(:username, message: "用户名已被人占用") 210 | |> unique_constraint(:email) 211 | end 212 | ``` 213 | 再跑一次测试,测试通过。 214 | 215 | 可是,如果我一定要用 `CHenxsan` 这个用户名呢?`String.downcase` 的处理方式,导致我们只能使用小写的 `chenxsan`。 216 | 217 | 我们还有个办法,只是比较复杂。 218 | 219 | ## 数据库迁移 220 | 221 | 在[用户注册一章](00-prepare.md),我们用 `mix phoenix.gen.html` 生成了许多样板文件,其中有一条: 222 | 223 | ```bash 224 | * creating priv/repo/migrations/20170123145857_create_user.exs 225 | ``` 226 | 227 | 打开该文件,它的内容如下: 228 | 229 | ```elixir 230 | defmodule TvRecipe.Repo.Migrations.CreateUser do 231 | use Ecto.Migration 232 | 233 | def change do 234 | create table(:users) do 235 | add :username, :string 236 | add :email, :string 237 | add :password, :string 238 | 239 | timestamps() 240 | end 241 | create unique_index(:users, [:username]) 242 | create unique_index(:users, [:email]) 243 | 244 | end 245 | end 246 | ``` 247 | 正是 `create unique_index(:users, [:username])` 一行,在数据库中限定了 `username` 的唯一性。 248 | 249 | 只是它没有处理大小写的问题。但我们能够处理处理,只要把它改成如下: 250 | 251 | ```elixir 252 | create unique_index(:users, ["lower(username)"]) 253 | ``` 254 | 那么要怎样去掉旧的 `unique_index` 而换上新的呢? 255 | 256 | Ecto 提供了一个 `mix ecto.gen.migration` [功能](https://hexdocs.pm/ecto/Mix.Tasks.Ecto.Gen.Migration.html)用于这类转换。 257 | 258 | 在命令行下创建一个试试: 259 | 260 | ```bash 261 | $ cd tv_recipe 262 | $ mix ecto.gen.migration alter_user_username_index 263 | * creating priv/repo/migrations 264 | * creating priv/repo/migrations/20170124123616_alter_user_username_index.exs 265 | ``` 266 | 打开新创建的 `20170124123616_alter_user_username_index.exs` 文件,做如下修改: 267 | 268 | ```elixir 269 | diff --git a/priv/repo/migrations/20170124123616_alter_user_username_index.exs b/priv/repo/migrations/20170124123616_alter_user_username_index.exs 270 | index 5723a10..4060abf 100644 271 | --- a/priv/repo/migrations/20170124123616_alter_user_username_index.exs 272 | +++ b/priv/repo/migrations/20170124123616_alter_user_username_index.exs 273 | @@ -2,6 +2,7 @@ defmodule TvRecipe.Repo.Migrations.AlterUserUsernameIndex do 274 | use Ecto.Migration 275 | 276 | def change do 277 | + drop index(:users, [:username]) # 移除旧索引 278 | + create unique_index(:users, ["lower(username)"]) # 增加新索引 279 | end 280 | end 281 | ``` 282 | 283 | 然后在命令行中执行 `mix ecto.migrate`,把迁移文件的修改落实到数据库中: 284 | 285 | ```bash 286 | $ mix ecto.migrate 287 | 288 | 20:39:44.900 [info] == Running TvRecipe.Repo.Migrations.AlterUserUsernameIndex.change/0 forward 289 | 290 | 20:39:44.900 [info] drop index users_username_index 291 | 292 | 20:39:44.930 [info] create index users_lower_username_index 293 | 294 | 20:39:44.940 [info] == Migrated in 0.0s 295 | ``` 296 | 297 | 最后要记得将此前 `user.ex` 文件中 `String.downcase` 的修改撤销掉: 298 | 299 | ```elixir 300 | diff --git a/web/models/user.ex b/web/models/user.ex 301 | index fc07824..88ad2af 100644 302 | --- a/web/models/user.ex 303 | +++ b/web/models/user.ex 304 | @@ -16,7 +16,6 @@ defmodule TvRecipe.User do 305 | struct 306 | |> cast(params, [:username, :email, :password]) 307 | |> validate_required([:username, :email, :password], message: "请填写") 308 | - |> update_change(:username, &String.downcase/1) 309 | |> unique_constraint(:username, message: "用户名已被人占用") 310 | |> unique_constraint(:email) 311 | end 312 | ``` 313 | 314 | 再运行测试看看: 315 | 316 | ```bash 317 | mix test test/models/user_test.exs 318 | warning: variable "changeset" is unused 319 | test/models/user_test.exs:42 320 | 321 | . 322 | 323 | 1) test username should be case insensitive (TvRecipe.UserTest) 324 | test/models/user_test.exs:36 325 | ** (Ecto.ConstraintError) constraint error when attempting to insert struct: 326 | 327 | * unique: users_lower_username_index 328 | 329 | If you would like to convert this constraint into an error, please 330 | call unique_constraint/3 in your changeset and define the proper 331 | constraint name. The changeset defined the following constraints: 332 | 333 | * unique: users_email_index 334 | * unique: users_username_index 335 | 336 | stacktrace: 337 | (ecto) lib/ecto/repo/schema.ex:493: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3 338 | (elixir) lib/enum.ex:1229: Enum."-map/2-lists^map/1-0-"/2 339 | (ecto) lib/ecto/repo/schema.ex:479: Ecto.Repo.Schema.constraints_to_errors/3 340 | (ecto) lib/ecto/repo/schema.ex:213: anonymous fn/13 in Ecto.Repo.Schema.do_insert/4 341 | test/models/user_test.exs:42: (test) 342 | 343 | . 344 | 345 | 2) test username should be unique (TvRecipe.UserTest) 346 | test/models/user_test.exs:24 347 | ** (Ecto.ConstraintError) constraint error when attempting to insert struct: 348 | 349 | * unique: users_lower_username_index 350 | 351 | If you would like to convert this constraint into an error, please 352 | call unique_constraint/3 in your changeset and define the proper 353 | constraint name. The changeset defined the following constraints: 354 | 355 | * unique: users_email_index 356 | * unique: users_username_index 357 | 358 | stacktrace: 359 | (ecto) lib/ecto/repo/schema.ex:493: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3 360 | (elixir) lib/enum.ex:1229: Enum."-map/2-lists^map/1-0-"/2 361 | (ecto) lib/ecto/repo/schema.ex:479: Ecto.Repo.Schema.constraints_to_errors/3 362 | (ecto) lib/ecto/repo/schema.ex:213: anonymous fn/13 in Ecto.Repo.Schema.do_insert/4 363 | test/models/user_test.exs:30: (test) 364 | 365 | . 366 | 367 | Finished in 0.1 seconds 368 | 5 tests, 2 failures 369 | ``` 370 | 情况变得更糟糕了,报告了 2 个错误。这是因为索引名称已经改变,而我们的代码还在使用默认的旧索引名。我们需要在 `unique_constraint` 里明确指出索引名称: 371 | 372 | ```elixir 373 | diff --git a/web/models/user.ex b/web/models/user.ex 374 | index 88ad2af..08e4054 100644 375 | --- a/web/models/user.ex 376 | +++ b/web/models/user.ex 377 | @@ -16,7 +16,7 @@ defmodule TvRecipe.User do 378 | struct 379 | |> cast(params, [:username, :email, :password]) 380 | |> validate_required([:username, :email, :password], message: "请填写") 381 | - |> unique_constraint(:username, message: "用户名已被人占用") 382 | + |> unique_constraint(:username, name: :users_lower_username_index, message: "用户名已被人占用") 383 | |> unique_constraint(:email) 384 | end 385 | end 386 | ``` 387 | 再跑一遍测试: 388 | 389 | ```bash 390 | $ mix test test/models/user_test.exs 391 | warning: variable "changeset" is unused 392 | test/models/user_test.exs:42 393 | 394 | ..... 395 | 396 | Finished in 0.1 seconds 397 | 5 tests, 0 failures 398 | ``` 399 | 悉数通过。 400 | 401 | 但眼尖的你可能已经注意到,我们的测试报告里有一条: 402 | 403 | > warning: variable "changeset" is unused 404 | 405 | 在 Elixir 下,如果有定义的变量未曾使用到,编译时就会出现警告。 406 | 407 | 上面这条警告对应的是测试代码中的这一行: 408 | 409 | ```elixir 410 | assert {:error, changeset} = TvRecipe.Repo.insert(another_user_changeset) 411 | ``` 412 | 我们只断定了插入数据库失败,还没有检查 `changeset` 里的错误。 413 | 414 | 让我们完善下测试: 415 | 416 | ```elixir 417 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 418 | index 9451c2d..975c7b1 100644 419 | --- a/test/models/user_test.exs 420 | +++ b/test/models/user_test.exs 421 | @@ -40,5 +40,6 @@ defmodule TvRecipe.UserTest do 422 | # 尝试插入大小写不一致的用户名,应报告错误 423 | another_user_changeset = User.changeset(%User{}, %{@valid_attrs | username: "Chenxsan", email: "chenxsan+1@gmail.com"}) 424 | assert {:error, changeset} = TvRecipe.Repo.insert(another_user_changeset) 425 | + assert {:username, "用户名已被人占用"} in errors_on(changeset) 426 | end 427 | end 428 | ``` 429 | 再次运行测试,悉数通过。 430 | 431 | 下一章,我们将[检查用户名的许可字符](03-username-format.md)。 -------------------------------------------------------------------------------- /04-user-register/03-username-format.md: -------------------------------------------------------------------------------- 1 | # username 只允许使用英文字母、数字及下划线 2 | 3 | 我们拟定的规则里,`username` 将只允许使用**英文字母**、**数字**及**下划线**。而目前样板生成的文件里,我们可以使用任何字符。 4 | 5 | 我们用测试来验证一下。 6 | 7 | 打开 `test/models/user_test.exs` 文件,添加一个测试: 8 | 9 | ```elixir 10 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 11 | index 975c7b1..644f4c3 100644 12 | --- a/test/models/user_test.exs 13 | +++ b/test/models/user_test.exs 14 | @@ -42,4 +42,10 @@ defmodule TvRecipe.UserTest do 15 | assert {:error, changeset} = TvRecipe.Repo.insert(another_user_changeset) 16 | assert {:username, "用户名已被人占用"} in errors_on(changeset) 17 | end 18 | + 19 | + test "username should only contains [a-zA-Z0-9_]" do 20 | + attrs = %{@valid_attrs | username: "陈三"} 21 | + changeset = User.changeset(%User{}, attrs) 22 | + refute changeset.valid? 23 | + end 24 | end 25 | ``` 26 | 命令行下运行测试得到的结果是: 27 | 28 | ```bash 29 | mix test test/models/user_test.exs 30 | .. 31 | 32 | 1) test username should only contains [a-zA-Z0-9_] (TvRecipe.UserTest) 33 | test/models/user_test.exs:46 34 | Expected false or nil, got true 35 | code: changeset.valid?() 36 | stacktrace: 37 | test/models/user_test.exs:49: (test) 38 | 39 | ... 40 | 41 | Finished in 0.1 seconds 42 | 6 tests, 1 failure 43 | ``` 44 | 在我们的规则里,用户名“陈三”是不允许的,但测试结果显示,目前它的被允许的。 45 | 46 | 显然,我们需要添加一个规则,在哪儿?怎么定义? 47 | 48 | 还是在 `web/models/user.ex` 文件中。 49 | 50 | 要限制字符,我们使用 [`validate_format`](https://hexdocs.pm/ecto/Ecto.Changeset.html#validate_format/4): 51 | 52 | ```elixir 53 | diff --git a/web/models/user.ex b/web/models/user.ex 54 | index 08e4054..7d7d59f 100644 55 | --- a/web/models/user.ex 56 | +++ b/web/models/user.ex 57 | @@ -16,6 +16,7 @@ defmodule TvRecipe.User do 58 | struct 59 | |> cast(params, [:username, :email, :password]) 60 | |> validate_required([:username, :email, :password], message: "请填写") 61 | + |> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/, message: "用户名只允许使用英文字母、数字及下划线") 62 | |> unique_constraint(:username, name: :users_lower_username_index, message: "用户名已被人占用") 63 | |> unique_constraint(:email) 64 | end 65 | ``` 66 | `~r/^[a-zA-Z0-9_]+$/` 是 elixir 的正则表达式,你如果不熟悉,可以试试这个在线工具 [http://www.elixre.uk/](http://www.elixre.uk/)。 67 | 68 | 现在再运行测试,悉数通过。 69 | 70 | 但我们还要再加一个测试,用于验证用户名格式出错时的提示信息。 71 | 72 | ```elixir 73 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 74 | index 644f4c3..73fc189 100644 75 | --- a/test/models/user_test.exs 76 | +++ b/test/models/user_test.exs 77 | @@ -48,4 +48,9 @@ defmodule TvRecipe.UserTest do 78 | changeset = User.changeset(%User{}, attrs) 79 | refute changeset.valid? 80 | end 81 | + 82 | + test "changeset with invalid username should throw errors" do 83 | + attrs = %{@valid_attrs | username: "陈三"} 84 | + assert {:username, "用户名只允许使用英文字母、数字及下划线"} in errors_on(%User{}, attrs) 85 | + end 86 | end 87 | ``` 88 | 89 | 运行测试,悉数通过。 90 | 91 | 下一章,我们将制定规则,[限制 `username` 的长度](04-username-length.md)。 -------------------------------------------------------------------------------- /04-user-register/04-username-length.md: -------------------------------------------------------------------------------- 1 | # username 限定长度值 2 | 3 | 这一章里,我们要限制 `username` 的长度值,两个错误提示分别如下: 4 | 5 | 1. 用户名最短 3 位 6 | 2. 用户名最长 15 位 7 | 8 | 老规矩,先写测试: 9 | 10 | ```elixir 11 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 12 | index 73fc189..26a7735 100644 13 | --- a/test/models/user_test.exs 14 | +++ b/test/models/user_test.exs 15 | @@ -53,4 +53,16 @@ defmodule TvRecipe.UserTest do 16 | attrs = %{@valid_attrs | username: "陈三"} 17 | assert {:username, "用户名只允许使用英文字母、数字及下划线"} in errors_on(%User{}, attrs) 18 | end 19 | + 20 | + test "username's length should be larger than 3" do 21 | + attrs = %{@valid_attrs | username: "ab"} 22 | + changeset = User.changeset(%User{}, attrs) 23 | + assert {:username, "用户名最短 3 位"} in errors_on(changeset) 24 | + end 25 | + 26 | + test "username's length should be less than 15" do 27 | + attrs = %{@valid_attrs | username: String.duplicate("a", 16)} 28 | + changeset = User.changeset(%User{}, attrs) 29 | + assert {:username, "用户名最长 15 位"} in errors_on(changeset) 30 | + end 31 | end 32 | ``` 33 | 34 | 显然,我们新增的这两个测试会失败,因为我们还没有加上限制规则。 35 | 36 | 打开 `web/models/user.ex` 文件,添加两条规则: 37 | 38 | ```elixir 39 | diff --git a/web/models/user.ex b/web/models/user.ex 40 | index 7d7d59f..8c68e6d 100644 41 | --- a/web/models/user.ex 42 | +++ b/web/models/user.ex 43 | @@ -17,6 +17,8 @@ defmodule TvRecipe.User do 44 | |> cast(params, [:username, :email, :password]) 45 | |> validate_required([:username, :email, :password], message: "请填写") 46 | |> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/, message: "用户名只允许使用英文字母、数字及下划线") 47 | + |> validate_length(:username, min: 3, message: "用户名最短 3 位") 48 | + |> validate_length(:username, max: 15, message: "用户名最长 15 位") 49 | |> unique_constraint(:username, name: :users_lower_username_index, message: "用户名已被人占用") 50 | |> unique_constraint(:email) 51 | end 52 | ``` 53 | [`validate_length`](https://hexdocs.pm/ecto/Ecto.Changeset.html#validate_length/3) 用于验证字段的长度值,`min` 参数用于指定最小值。 54 | 55 | 现在运行测试,悉数通过。 56 | 57 | 你可能已经发现,我们有大量类似 `validate_length`、`validate_format` 的函数可以使用,我们要做的,只是定义我们的需求,然后找出相应的函数 - 非常轻松。 58 | 59 | 下一章,我们将[禁止用户注册 `admin`](05-username-exclude.md) 等用户名。 -------------------------------------------------------------------------------- /04-user-register/05-username-exclude.md: -------------------------------------------------------------------------------- 1 | # username 禁止使用 `admin` 等 2 | 3 | 为避免用户混淆,网站通常都会保留一系列用户名,不开放给普通用户使用,比如 `admin`、`administrator`,TvRecipe 项目里,我们将保留这两个用户名,禁止用户注册,如果有人尝试使用它们注册,则提示`系统保留,无法注册,请更换`。 4 | 5 | 我们从测试写起: 6 | 7 | ```elixir 8 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 9 | index 26a7735..f70d4a1 100644 10 | --- a/test/models/user_test.exs 11 | +++ b/test/models/user_test.exs 12 | @@ -65,4 +65,9 @@ defmodule TvRecipe.UserTest do 13 | changeset = User.changeset(%User{}, attrs) 14 | assert {:username, "用户名最长 15 位"} in errors_on(changeset) 15 | end 16 | + 17 | + test "username should not be admin or administrator" do 18 | + assert {:username, "系统保留,无法注册,请更换"} in errors_on(%User{}, %{@valid_attrs | username: "admin"}) 19 | + assert {:username, "系统保留,无法注册,请更换"} in errors_on(%User{}, %{@valid_attrs | username: "administrator"}) 20 | + end 21 | end 22 | ``` 23 | 24 | 然后是添加规则,照例还是在 `user.ex` 文件中: 25 | 26 | ```elixir 27 | diff --git a/web/models/user.ex b/web/models/user.ex 28 | index 8c68e6d..35e4d0b 100644 29 | --- a/web/models/user.ex 30 | +++ b/web/models/user.ex 31 | @@ -19,6 +19,7 @@ defmodule TvRecipe.User do 32 | |> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/, message: "用户名只允许使用英文字母、数字及下划线") 33 | |> validate_length(:username, min: 3, message: "用户名最短 3 位") 34 | |> validate_length(:username, max: 15, message: "用户名最长 15 位") 35 | + |> validate_exclusion(:username, ~w(admin administrator), message: "系统保留,无法注册,请更换") 36 | |> unique_constraint(:username, name: :users_lower_username_index, message: "用户名已被人占用") 37 | |> unique_constraint(:email) 38 | end 39 | ``` 40 | 这里,我们用 [`validate_exclusion`](https://hexdocs.pm/ecto/Ecto.Changeset.html#validate_exclusion/4) 来排除 [`~w(admin administrator)` 数组](http://elixir-lang.org/getting-started/sigils.html#word-lists)中的两个用户名。 41 | 42 | 再运行测试,悉数通过。 43 | 44 | 这样,我们就完成了所有 `username` 有关的规则,下一章,我们开始编写 [`email` 相关的规则](06-email-rules.md)。 -------------------------------------------------------------------------------- /04-user-register/06-email-rules.md: -------------------------------------------------------------------------------- 1 | # email 规则 2 | 3 | 邮箱的限制有如下三个: 4 | 5 | 限制|错误提示 6 | ---|--- 7 | 必填|请填写 8 | 不能重复|邮箱已被人占用 9 | 邮箱必须包含 `@` 字符|邮箱格式错误 10 | 11 | 因为我们在前几章的 `username` 里涉及过这三个规则,所以这里不再啰嗦分出章节。 12 | 13 | ## email 必填 14 | 15 | 首先,添加测试规则,验证 `email` 为空时的错误提示: 16 | 17 | ```elixir 18 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 19 | index f70d4a1..bae1e57 100644 20 | --- a/test/models/user_test.exs 21 | +++ b/test/models/user_test.exs 22 | @@ -70,4 +70,9 @@ defmodule TvRecipe.UserTest do 23 | assert {:username, "系统保留,无法注册,请更换"} in errors_on(%User{}, %{@valid_attrs | username: "admin"}) 24 | assert {:username, "系统保留,无法注册,请更换"} in errors_on(%User{}, %{@valid_attrs | username: "administrator"}) 25 | end 26 | + 27 | + test "email should not be blank" do 28 | + attrs = %{@valid_attrs | email: ""} 29 | + assert {:email, "请填写"} in errors_on(%User{}, attrs) 30 | + end 31 | end 32 | ``` 33 | 因为我们前面在处理 [`username` 必填](01-username-required.md)时,也一起处理过 `email`,所以这个测试是会通过的。 34 | 35 | ## email 格式 36 | 37 | 因为邮箱地址的格式花样太多,所以这里只简单验证用户填写的邮箱地址中是否包含 `@` 字符。一般情况下,在用户注册成功后,系统会发送一封确认邮件到用户邮箱,但因为邮件系统涉及第三方服务,所以本教程不做展开。 38 | 39 | 我们先添加一个测试: 40 | 41 | ```elixir 42 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 43 | index bae1e57..67aab23 100644 44 | --- a/test/models/user_test.exs 45 | +++ b/test/models/user_test.exs 46 | @@ -75,4 +75,9 @@ defmodule TvRecipe.UserTest do 47 | attrs = %{@valid_attrs | email: ""} 48 | assert {:email, "请填写"} in errors_on(%User{}, attrs) 49 | end 50 | + 51 | + test "email should contain @" do 52 | + attrs = %{@valid_attrs | email: "ab"} 53 | + assert {:email, "邮箱格式错误"} in errors_on(%User{}, attrs) 54 | + end 55 | end 56 | ``` 57 | 58 | 因为我们的规则还没写,所以测试不会通过。 59 | 60 | 下面在 `user.ex` 文件中添加 `validate_format` 验证规则: 61 | 62 | ```elixir 63 | diff --git a/web/models/user.ex b/web/models/user.ex 64 | index 35e4d0b..fef942b 100644 65 | --- a/web/models/user.ex 66 | +++ b/web/models/user.ex 67 | @@ -21,6 +21,7 @@ defmodule TvRecipe.User do 68 | |> validate_length(:username, max: 15, message: "用户名最长 15 位") 69 | |> validate_exclusion(:username, ~w(admin administrator), message: "系统保留,无法注册,请更换") 70 | |> unique_constraint(:username, name: :users_lower_username_index, message: "用户名已被人占用") 71 | + |> validate_format(:email, ~r/@/, message: "邮箱格式错误") 72 | |> unique_constraint(:email) 73 | end 74 | end 75 | ``` 76 | 77 | 再运行测试,悉数通过。 78 | 79 | ## email 不允许重复 80 | 81 | 仍是先写测试: 82 | 83 | ```elixir 84 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 85 | index 67aab23..f6c99e5 100644 86 | --- a/test/models/user_test.exs 87 | +++ b/test/models/user_test.exs 88 | @@ -80,4 +80,16 @@ defmodule TvRecipe.UserTest do 89 | attrs = %{@valid_attrs | email: "ab"} 90 | assert {:email, "邮箱格式错误"} in errors_on(%User{}, attrs) 91 | end 92 | + 93 | + test "email should be unique" do 94 | + # 在测试数据库中插入新用户 95 | + user_changeset = User.changeset(%User{}, @valid_attrs) 96 | + TvRecipe.Repo.insert! user_changeset 97 | + 98 | + # 尝试插入同邮箱地址的用户,应报告错误 99 | + assert {:error, changeset} = TvRecipe.Repo.insert(User.changeset(%User{}, %{@valid_attrs | username: "samchen"})) 100 | + 101 | + # 错误信息为“邮箱已被人占用” 102 | + assert {:email, "邮箱已被人占用"} in errors_on(changeset) 103 | + end 104 | end 105 | ``` 106 | 107 | 然后给 `unique_constraint` 添加自定义消息。 108 | 109 | 打开 `user.ex` 文件,添加 `message` 如下: 110 | 111 | ```elixir 112 | diff --git a/web/models/user.ex b/web/models/user.ex 113 | index fef942b..54e7e4c 100644 114 | --- a/web/models/user.ex 115 | +++ b/web/models/user.ex 116 | @@ -22,6 +22,6 @@ defmodule TvRecipe.User do 117 | |> validate_exclusion(:username, ~w(admin administrator), message: "系统保留,无法注册,请更换") 118 | |> unique_constraint(:username, name: :users_lower_username_index, message: "用户名已被人占用") 119 | |> validate_format(:email, ~r/@/, message: "邮箱格式错误") 120 | - |> unique_constraint(:email) 121 | + |> unique_constraint(:email, message: "邮箱已被人占用") 122 | end 123 | end 124 | ``` 125 | 126 | 再运行测试,就都通过了。 127 | 128 | 最后,还有一个测试,是关于 `email` 大小写的,即 `a@b` 与 `A@b` 应当认为是一致的: 129 | 130 | ```elixir 131 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 132 | index f6c99e5..82dcf6a 100644 133 | --- a/test/models/user_test.exs 134 | +++ b/test/models/user_test.exs 135 | @@ -92,4 +92,14 @@ defmodule TvRecipe.UserTest do 136 | # 错误信息为“邮箱已被人占用” 137 | assert {:email, "邮箱已被人占用"} in errors_on(changeset) 138 | end 139 | + 140 | + test "email should be case insensitive" do 141 | + user_changeset = User.changeset(%User{}, @valid_attrs) 142 | + TvRecipe.Repo.insert! user_changeset 143 | + 144 | + # 尝试插入大小写不一致的邮箱,应报告错误 145 | + another_user_changeset = User.changeset(%User{}, %{@valid_attrs | username: "samchen", email: "chenXsan@gmail.com"}) 146 | + assert {:error, changeset} = TvRecipe.Repo.insert(another_user_changeset) 147 | + assert {:email, "邮箱已被人占用"} in errors_on(changeset) 148 | + end 149 | end 150 | ``` 151 | 152 | 现在,测试是失败的。 153 | 154 | 如果你忘了接下来要怎么处理,请先打开 [username 已被人占用](02-username-unique.md)一章,回顾一下。 155 | 156 | 我们的步骤是这样的: 157 | 158 | 1. 执行 `mix ecto.gen.migration alter_email_index` 命令新建一个 migration 文件: 159 | 160 | ```bash 161 | $ mix ecto.gen.migration alter_email_index 162 | Compiling 2 files (.ex) 163 | * creating priv/repo/migrations 164 | * creating priv/repo/migrations/20170124142809_alter_email_index.exs 165 | ``` 166 | 2. 打开新建的 `20170124142809_alter_email_index.exs` 文件,做如下修改: 167 | 168 | ```elixir 169 | diff --git a/priv/repo/migrations/20170124142809_alter_email_index.exs b/priv/repo/migrations/20170124142809_alter_email_index.exs 170 | index 313bda6..746a00d 100644 171 | --- a/priv/repo/migrations/20170124142809_alter_email_index.exs 172 | +++ b/priv/repo/migrations/20170124142809_alter_email_index.exs 173 | @@ -2,6 +2,7 @@ defmodule TvRecipe.Repo.Migrations.AlterEmailIndex do 174 | use Ecto.Migration 175 | 176 | def change do 177 | - 178 | + drop index(:users, [:email]) # 移除旧索引 179 | + create unique_index(:users, ["lower(email)"]) # 增加新索引 180 | end 181 | end 182 | ``` 183 | 3. 接着在命令行下执行 `mix ecto.migrate` 命令: 184 | 185 | ```bash 186 | $ mix ecto.migrate 187 | 188 | 22:30:46.531 [info] == Running TvRecipe.Repo.Migrations.AlterEmailIndex.change/0 forward 189 | 190 | 22:30:46.531 [info] drop index users_email_index 191 | 192 | 22:30:46.532 [info] create index users_lower_email_index 193 | 194 | 22:30:46.568 [info] == Migrated in 0.0s 195 | ``` 196 | 4. 最后,将新索引的名称赋给 `unique_constraint` 的 `name` 参数: 197 | 198 | ```elixir 199 | diff --git a/web/models/user.ex b/web/models/user.ex 200 | index 54e7e4c..9307a3c 100644 201 | --- a/web/models/user.ex 202 | +++ b/web/models/user.ex 203 | @@ -22,6 +22,6 @@ defmodule TvRecipe.User do 204 | |> validate_exclusion(:username, ~w(admin administrator), message: "系统保留,无法注册,请更换") 205 | |> unique_constraint(:username, name: :users_lower_username_index, message: "用户名已被人占用") 206 | |> validate_format(:email, ~r/@/, message: "邮箱格式错误") 207 | - |> unique_constraint(:email, message: "邮箱已被人占用") 208 | + |> unique_constraint(:email, name: :users_lower_email_index, message: "邮箱已被人占用") 209 | end 210 | end 211 | ``` 212 | 再跑一遍测试: 213 | 214 | ```bash 215 | $ mix test test/models/user_test.exs 216 | .............. 217 | 218 | Finished in 0.2 seconds 219 | 14 tests, 0 failures 220 | ``` 221 | 通过了。这样,我们就搞定了 `email` 所有规则。如果你心里不踏实,可以打开浏览器页面人肉测试一番 - 但建议你不要,要控制住这种无用的欲望。 222 | 223 | 下一章,我们开始编写 [`password` 规则](07-password-rules.md)。 -------------------------------------------------------------------------------- /04-user-register/07-password-rules.md: -------------------------------------------------------------------------------- 1 | # password 规则 2 | 3 | `password` 的规则与 [`email` 规则类似](06-email-rules.md),同样有三条: 4 | 5 | 限制|错误提示 6 | ---|--- 7 | 必填|请填写 8 | 密码最短 6 位|密码最短 6 位 9 | 密码不能明文存储在数据库中|- 10 | 11 | 但我们会分两章完成,这一章里完成前面两条规则,“密码不能明文存储”规则因为较复杂,所以放到下一章里。 12 | 13 | 首先,是添加两个测试: 14 | 15 | ```elixir 16 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 17 | index 82dcf6a..8689f4e 100644 18 | --- a/test/models/user_test.exs 19 | +++ b/test/models/user_test.exs 20 | @@ -102,4 +102,14 @@ defmodule TvRecipe.UserTest do 21 | assert {:error, changeset} = TvRecipe.Repo.insert(another_user_changeset) 22 | assert {:email, "邮箱已被人占用"} in errors_on(changeset) 23 | end 24 | + 25 | + test "password is required" do 26 | + attrs = %{@valid_attrs | password: ""} 27 | + assert {:password, "请填写"} in errors_on(%User{}, attrs) 28 | + end 29 | + 30 | + test "password's length should be larger than 6" do 31 | + attrs = %{@valid_attrs | password: String.duplicate("1", 5)} 32 | + assert {:password, "密码最短 6 位"} in errors_on(%User{}, attrs) 33 | + end 34 | end 35 | ``` 36 | 37 | 一个验证 `password` 必填,一个验证密码长度。 38 | 39 | 这两个测试,必填的一个会通过,因为在处理 `username` 时一起处理了;验证密码长度的则会失败,因为我们的规则还没写。 40 | 41 | 打开 `user.ex` 文件,添加一行 `validate_length`: 42 | 43 | ```elixir 44 | diff --git a/web/models/user.ex b/web/models/user.ex 45 | index 9307a3c..3069e79 100644 46 | --- a/web/models/user.ex 47 | +++ b/web/models/user.ex 48 | @@ -23,5 +23,6 @@ defmodule TvRecipe.User do 49 | |> unique_constraint(:username, name: :users_lower_username_index, message: "用户名已被人占用") 50 | |> validate_format(:email, ~r/@/, message: "邮箱格式错误") 51 | |> unique_constraint(:email, name: :users_lower_email_index, message: "邮箱已被人占用") 52 | + |> validate_length(:password, min: 6, message: "密码最短 6 位") 53 | end 54 | end 55 | ``` 56 | 再运行测试,全部通过。 57 | 58 | 这样,我们就完成了 `password` 的前两条验证规则,[下一章](08-password-storage.md),我们将探索如何在数据库安全存储密码。 -------------------------------------------------------------------------------- /04-user-register/08-password-storage.md: -------------------------------------------------------------------------------- 1 | # 安全存储密码 2 | 3 | 如果你在前面章节里,曾在浏览器里打开过页面,注册过用户,则打开 [http://localhost:4000/users](http://localhost:4000/users) 网址,你会看到类似如下截图的内容: 4 | 5 | ![users list](../img/04-user-list.png) 6 | 7 | 密码字段一览无余,如果数据库被人入侵,则用户密码全部暴露。 8 | 9 | 所以,这一章里,我们要先对用户密码做哈希处理,然后才保存到数据库中。我们要用到第三方的 [Comeonin](https://github.com/riverrun/comeonin) 库。 10 | 11 | ## 添加依赖 12 | 13 | 首先,打开项目依赖管理文件 `mix.exs`,在文件中添加 `comeonin` 依赖: 14 | 15 | ```elixir 16 | diff --git a/mix.exs b/mix.exs 17 | index a71d654..3320fc8 100644 18 | --- a/mix.exs 19 | +++ b/mix.exs 20 | @@ -19,7 +19,7 @@ defmodule TvRecipe.Mixfile do 21 | def application do 22 | [mod: {TvRecipe, []}, 23 | applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext, 24 | - :phoenix_ecto, :postgrex]] 25 | + :phoenix_ecto, :postgrex, :comeonin]] 26 | end 27 | 28 | # Specifies which paths to compile per environment. 29 | @@ -37,7 +37,8 @@ defmodule TvRecipe.Mixfile do 30 | {:phoenix_html, "~> 2.6"}, 31 | {:phoenix_live_reload, "~> 1.0", only: :dev}, 32 | {:gettext, "~> 0.11"}, 33 | - {:cowboy, "~> 1.0"}] 34 | + {:cowboy, "~> 1.0"}, 35 | + {:comeonin, "~> 3.0"}] 36 | end 37 | ``` 38 | 39 | 我们在 `mix.exs` 文件中共添加了两处代码,一处是 `deps` 函数中,定义我们要用的 `comeonin` 版本;另一处是 `application` 函数中,表示构建时应将 `comeonin` 打包进去。 40 | 41 | 接着在命令行下执行: 42 | 43 | ```bash 44 | $ mix do deps.get, compile 45 | ``` 46 | 该命令从远程下载了我们新增的 `comeonin` 依赖并编译。 47 | 48 | 那么,怎么确认 `comeonin` 安装成功?之前,我们一直是用 `mix phoenix.server` 命令来启动服务器的,接下来,我们要换一种启动方式: 49 | 50 | ```bash 51 | $ iex -S mix phoenix.server 52 | ``` 53 | 区别在哪?我们来看看后者启动后的结果: 54 | 55 | ``` 56 | $ iex -S mix phoenix.server 57 | Erlang/OTP 19 [erts-8.2] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace] 58 | 59 | [info] Running TvRecipe.Endpoint with Cowboy using http://localhost:4000 60 | Interactive Elixir (1.4.0) - press Ctrl+C to exit (type h() ENTER for help) 61 | iex(1)> 25 Jan 09:53:09 - info: compiled 6 files into 2 files, copied 3 in 2.1 sec 62 | ``` 63 | 看到区别了么?我们用 `iex -S mix phoenix.server` 启动后,可以使用 Elixir 的 [`iex`](http://elixir-lang.org/docs/stable/iex/IEx.html)。 64 | 65 | 比如,我们可以输入 `Com` 然后按 Tab 键: 66 | 67 | ![iex tab 补全](../img/04-iex-shell.png) 68 | 69 | `iex` 下自动补全 `Comeonin`,证明我们已经可以在 TvRecipe 项目中使用它。 70 | 71 | ## `password` 字段的处理 72 | 73 | 我们现在面对的情况是,数据库不应该存储 `password`,因为它是明文的。我们要把哈希处理后的密码存入另一个字段,比如 `password_hash`。 74 | 75 | 但我们的数据库现在只有 `password` 字段,还没有 `password_hash`。怎么办?我们仍通过迁移(migration)来做增删。 76 | 77 | 1. 创建 migration 文件 78 | 79 | ```bash 80 | $ mix ecto.gen.migration alter_user_table 81 | * creating priv/repo/migrations 82 | * creating priv/repo/migrations/20170125015912_alter_user_table.exs 83 | ``` 84 | 2. 打开新建的 `20170125015912_alter_user_table.exs` 文件,[`remove`](https://hexdocs.pm/ecto/Ecto.Migration.html#remove/1) 掉 `password` 字段,然后 [`add`](https://hexdocs.pm/ecto/Ecto.Migration.html#add/3) `password_hash` 字段: 85 | 86 | ```elixir 87 | diff --git a/priv/repo/migrations/20170125015912_alter_user_table.exs b/priv/repo/migrations/20170125015912_alter_user_table.exs 88 | index 2a25ba8..e783c65 100644 89 | --- a/priv/repo/migrations/20170125015912_alter_user_table.exs 90 | +++ b/priv/repo/migrations/20170125015912_alter_user_table.exs 91 | @@ -2,6 +2,9 @@ defmodule TvRecipe.Repo.Migrations.AlterUserTable do 92 | use Ecto.Migration 93 | 94 | def change do 95 | - 96 | + alter table(:users) do 97 | + remove :password 98 | + add :password_hash, :string 99 | + end 100 | end 101 | end 102 | ``` 103 | 3. 执行 `mix ecto.migrate` 修改数据库: 104 | 105 | ```bash 106 | $ mix ecto.migrate 107 | 108 | 10:17:57.648 [info] == Running TvRecipe.Repo.Migrations.AlterUserTable.change/0 forward 109 | 110 | 10:17:57.648 [info] alter table users 111 | 112 | 10:17:57.685 [info] == Migrated in 0.0s 113 | ``` 114 | 4. 在上一步里,我们修改了数据库里 `users` 表的结构。那么,`user.ex` 文件中的 `password` 字段怎么办?要删除吗?删除了话,前面做的那些围绕 `password` 的验证怎么办? 115 | 116 | 不,我们留着 `password`,但要给它加上 `virtual: true`,表示它是个临时字段,不存储到数据库中: 117 | 118 | ```elixir 119 | diff --git a/web/models/user.ex b/web/models/user.ex 120 | index 3069e79..e60e839 100644 121 | --- a/web/models/user.ex 122 | +++ b/web/models/user.ex 123 | @@ -4,7 +4,8 @@ defmodule TvRecipe.User do 124 | schema "users" do 125 | field :username, :string 126 | field :email, :string 127 | - field :password, :string 128 | + field :password, :string, virtual: true 129 | + field :password_hash, :string 130 | 131 | timestamps() 132 | end 133 | end 134 | ``` 135 | 你可能会好奇,不加 `virtual: true` 会怎样,会这样: 136 | 137 | ```bash 138 | ** (Postgrex.Error) ERROR (undefined_column): column "password" of relation "users" does not exist 139 | (ecto) lib/ecto/adapters/sql.ex:463: Ecto.Adapters.SQL.struct/6 140 | (ecto) lib/ecto/repo/schema.ex:397: Ecto.Repo.Schema.apply/4 141 | (ecto) lib/ecto/repo/schema.ex:193: anonymous fn/11 in Ecto.Repo.Schema.do_insert/4 142 | (ecto) lib/ecto/repo/schema.ex:124: Ecto.Repo.Schema.insert!/4 143 | ``` 144 | 因为数据表里已经移除了 `password` 字段,数据也就无法插入。 145 | 146 | 话说回来,我们做了这么多的修改,是否破坏了代码呢?我们可以运行测试,确证下。 147 | 148 | 测试证明,一切顺利。我们继续。 149 | 150 | ## 存储哈希后的密码 151 | 152 | 我们至今还没有提过 [Changeset](https://hexdocs.pm/ecto/Ecto.Changeset.html) 的涵义。 153 | 154 | 官方的文档是这样说的: 155 | 156 | > Changesets allow filtering, casting, validation and definition of constraints when manipulating structs. 157 | 158 | 通俗点讲,它是一种数据处理机制,数据在插入数据库前,先要经过一系列**流程**,验证数据的正确,保证数据的唯一等等,没有错误,数据才插入到表中,如果有错误,则将错误写入统一的格式中,方便我们处理。 159 | 160 | 再回到我们的问题,我们能够从 changeset 里得到 `password` 的数据,接下来要怎么处理? 161 | 162 | `user.ex` 文件现在的 `changeset` 函数是这样的: 163 | 164 | ```elixir 165 | def changeset(struct, params \\ %{}) do 166 | struct 167 | |> cast(params, [:username, :email, :password]) 168 | |> validate_required([:username, :email, :password], message: "请填写") 169 | |> validate_format(:username, ~r/^[a-zA-Z0-9_]+$/, message: "用户名只允许使用英文字母、数字及下划线") 170 | |> validate_length(:username, min: 3, message: "用户名最短 3 位") 171 | |> validate_length(:username, max: 15, message: "用户名最长 15 位") 172 | |> validate_exclusion(:username, ~w(admin administrator), message: "系统保留,无法注册,请更换") 173 | |> unique_constraint(:username, name: :users_lower_username_index, message: "用户名已被人占用") 174 | |> validate_format(:email, ~r/@/, message: "邮箱格式错误") 175 | |> unique_constraint(:email, name: :users_lower_email_index, message: "邮箱已被人占用") 176 | |> validate_length(:password, min: 6, message: "密码最短 6 位") 177 | end 178 | ``` 179 | 我们可以在 `changeset` 末尾再加一道工序: 180 | 181 | ```elixir 182 | diff --git a/web/models/user.ex b/web/models/user.ex 183 | index e60e839..58447c0 100644 184 | --- a/web/models/user.ex 185 | +++ b/web/models/user.ex 186 | @@ -25,5 +25,6 @@ defmodule TvRecipe.User do 187 | |> validate_format(:email, ~r/@/, message: "邮箱格式错误") 188 | |> unique_constraint(:email, name: :users_lower_email_index, message: "邮箱已被人占用") 189 | |> validate_length(:password, min: 6, message: "密码最短 6 位") 190 | + |> put_password_hash() 191 | end 192 | end 193 | ``` 194 | 顺便解释一下,[`|>`](http://elixir-lang.org/getting-started/enumerables-and-streams.html#the-pipe-operator) 是 Elixir 的管道操作符,如果你用过 Linux/Unix 的 pipe,你可能已经很清楚。 195 | 196 | 如果你没用过,则可以这么理解,`|>` 前的函数会返回一个数据,这个数据作为第一个参数传入给 `|>` 后函数。 197 | 198 | 拿上面的 `changeset` 函数说,它等同于: 199 | 200 | ```elixir 201 | # 接收上一个 changeset,返回一个新的 changeset 202 | changeset = validate_length(changeset, :password, min: 6, message: "密码最短 6 位") 203 | # 接收上一个 changeset,返回一个新的 changeset 204 | changeset = put_password_hash(changeset) 205 | ``` 206 | 当然,没人喜欢这么写。 207 | 208 | 现在,我们来定义 `put_password_hash` 函数: 209 | 210 | ```elixir 211 | diff --git a/web/models/user.ex b/web/models/user.ex 212 | index 58447c0..690a1ed 100644 213 | --- a/web/models/user.ex 214 | +++ b/web/models/user.ex 215 | @@ -27,4 +27,13 @@ defmodule TvRecipe.User do 216 | |> validate_length(:password, min: 6, message: "密码最短 6 位") 217 | |> put_password_hash() 218 | end 219 | + 220 | + defp put_password_hash(changeset) do 221 | + case changeset do 222 | + %Ecto.Changeset{valid?: true, changes: %{password: password}} -> 223 | + put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password)) 224 | + _ -> 225 | + changeset 226 | + end 227 | + end 228 | end 229 | ``` 230 | 231 | 这里,涉及了 Elixir 的几个知识。 232 | 233 | 不过先插一段闲话。不知道你发现没有,从第一章到现在,我都没有提过,学习 Phoenix Framework 要掌握 Elixir 到什么程度。从我个人的经验说,哪怕不懂 Elixir,也是可以学 Phoenix 的。**用**是最快的学习方式,碰上不懂的,再去借助搜索引擎,这样才有的放矢。等到时机成熟,再完整地学习一遍 Elixir,因为有了实践经验,一切就会水到渠成。 234 | 235 | 好了,我们来解释下上面的几个新知识点: 236 | 237 | 1. `defp` - 我们之前就接触过 `def`,它用于定义函数,而 `defp` 是定义一个隐私(private)函数,隐私函数只能在它所在的模块内部使用 238 | 2. `case do` - 根据不同匹配结果执行不同代码,类似 `if else` 239 | 3. 模式匹配(pattern matching)- [模式匹配](http://elixir-lang.org/getting-started/pattern-matching.html) 是 Elixir 很重要的一个特性,利用它,我们能够很方便的从数据中提取数据,以上面定义的 `put_password_hash` 函数来说,`changeset = %Ecto.Changeset{valid?: true, changes: %{password: password}}` 就可以把 `password` 的值提取出来。 240 | 4. `put_change` - 修改 changeset 中的数据 241 | 242 | 这样,我们就完成密码的安全存储。 243 | 244 | 当然,我们还要添加一个测试,用 [Comeonin.Bcrypt.checkpw](https://hexdocs.pm/comeonin/Comeonin.Bcrypt.html#checkpw/2) 来保证 `put_password_hash` 函数的结果: 245 | 246 | ```elixir 247 | diff --git a/test/models/user_test.exs b/test/models/user_test.exs 248 | index 8689f4e..6e946b0 100644 249 | --- a/test/models/user_test.exs 250 | +++ b/test/models/user_test.exs 251 | @@ -112,4 +112,9 @@ defmodule TvRecipe.UserTest do 252 | attrs = %{@valid_attrs | password: String.duplicate("1", 5)} 253 | assert {:password, "密码最短 6 位"} in errors_on(%User{}, attrs) 254 | end 255 | + 256 | + test "password should be hashed" do 257 | + %{changes: changes} = User.changeset(%User{}, @valid_attrs) 258 | + assert Comeonin.Bcrypt.checkpw(changes.password, changes.password_hash) 259 | + end 260 | end 261 | ``` 262 | 263 | 运行测试: 264 | 265 | ```bash 266 | mix test test/models/user_test.exs 267 | ................. 268 | 269 | Finished in 4.2 seconds 270 | 17 tests, 0 failures 271 | ``` 272 | 如果你眼尖,可能已经已经注意到,我们的测试时间变长了,之前多是零点几秒,现在一下变成 4.2 秒。 273 | 274 | 这是引入的 `comeonin` 依赖导致的,密码加密需要大量时间,而我们在测试时,并不需要高强度的密码加密。 275 | 276 | 我们可以在 `test.exs` 文件中 调整 `comeonin` 的[配置](https://hexdocs.pm/comeonin/Comeonin.Config.html#module-examples): 277 | 278 | ```elixir 279 | diff --git a/config/test.exs b/config/test.exs 280 | index 0ff4a98..1743d57 100644 281 | --- a/config/test.exs 282 | +++ b/config/test.exs 283 | @@ -17,3 +17,7 @@ config :tv_recipe, TvRecipe.Repo, 284 | database: "tv_recipe_test", 285 | hostname: "localhost", 286 | pool: Ecto.Adapters.SQL.Sandbox 287 | + 288 | +config :comeonin, 289 | + bcrypt_log_rounds: 4, 290 | + pbkdf2_rounds: 1_000 291 | ``` 292 | 再次运行测试: 293 | 294 | ```bash 295 | mix test test/models/user_test.exs 296 | ................. 297 | 298 | Finished in 0.2 seconds 299 | 17 tests, 0 failures 300 | ``` 301 | 我们的测试又快起来了。 302 | 303 | 下一章里,我们将做一些[扫尾工作](09-optimize-ui.md),然后结束用户注册模块。 304 | -------------------------------------------------------------------------------- /04-user-register/09-optimize-ui.md: -------------------------------------------------------------------------------- 1 | # 优化用户注册界面 2 | 3 | 截止上一章,我们基本完成用户注册的所有逻辑,但界面上还有些问题需要解决。 4 | 5 | 1. 错误信息不明显 6 | 7 | ![Phoenix 用户名不为空的错误信息](../img/04-users-blank-username.png) 8 | 9 | 截图中可以看到,“请填写”三个字不突出,很多时候用户会视而不见。 10 | 11 | 2. 密码输入框 12 | 13 | ![密码输入框](../img/04-password-input.png) 14 | 15 | 在密码框中输入的内容,现在是明文显示,通常常是用 * 号代替。 16 | 17 | 第 1 个问题。 18 | 19 | Phoenix 生成的 `form.html.eex` 模板里使用了 Bootstrap [样式](https://getbootstrap.com/css/#forms-control-validation): 20 | 21 | ```eex 22 |
23 | <%= label f, :username, class: "control-label" %> 24 | <%= text_input f, :username, class: "form-control" %> 25 | <%= error_tag f, :username %> 26 |
27 | ``` 28 | 但模板中生成的样式与 Bootstrap 的比,差了 `has-error` 这样的 CSS 状态类。我们可以给它补上: 29 | 30 | ```eex 31 | diff --git a/web/templates/user/form.html.eex b/web/templates/user/form.html.eex 32 | index 5857c33..b047466 100644 33 | --- a/web/templates/user/form.html.eex 34 | +++ b/web/templates/user/form.html.eex 35 | @@ -5,19 +5,19 @@ 36 | 37 | <% end %> 38 | 39 | -
40 | +
"> 41 | <%= label f, :username, class: "control-label" %> 42 | <%= text_input f, :username, class: "form-control" %> 43 | <%= error_tag f, :username %> 44 |
45 | 46 | -
47 | +
"> 48 | <%= label f, :email, class: "control-label" %> 49 | <%= text_input f, :email, class: "form-control" %> 50 | <%= error_tag f, :email %> 51 |
52 | 53 | -
54 | +
"> 55 | <%= label f, :password, class: "control-label" %> 56 | <%= text_input f, :password, class: "form-control" %> 57 | <%= error_tag f, :password %> 58 | ``` 59 | 这样我们的错误提示界面就会变成: 60 | 61 | ![用户名不为空](../img/04-username-has-error.png) 62 | 63 | 非常醒目。至于 Phoenix 生成的模板里为什么不带 `has-error`,可以看 [github 上的一个 issue](https://github.com/phoenixframework/phoenix/issues/1961)。 64 | 65 | 第 2 个问题就容易解决了,我们来看现有代码: 66 | 67 | ```eex 68 |
"> 69 | <%= label f, :password, class: "control-label" %> 70 | <%= text_input f, :password, class: "form-control" %> 71 | <%= error_tag f, :password %> 72 |
73 | ``` 74 | 生成的模板里现在用了 `text_input`,它本来就是明文显示的,改为 [`password_input`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#password_input/3) 后,界面上就会用 * 号代替我们的输入。 75 | 76 | 这样,我们结束了用户注册模块。接下来,我们将开始开发用户的登录/退出功能。 77 | 78 | 但是,且慢,还有一个差点被我们遗忘的。 79 | 80 | ## 控制器的测试 81 | 82 | 你可能对 `mix test test/models/user_test.exs` 命令已经烂熟于心。但 `mix test test/controllers/user_controller_test.exs` 呢? 83 | 84 | 我们在生成用户的样板文件时,曾经生成过一个 `user_controller_test.exs` 文件,让我们运行下 `mix test test/controllers/user_controller_test.exs` 看看结果: 85 | 86 | ```bash 87 | $ mix test test/controllers/user_controller_test.exs 88 | Compiling 1 file (.ex) 89 | .... 90 | 91 | 1) test updates chosen resource and redirects when data is valid (TvRecipe.UserControllerTest) 92 | test/controllers/user_controller_test.exs:47 93 | ** (RuntimeError) expected redirection with status 302, got: 200 94 | stacktrace: 95 | (phoenix) lib/phoenix/test/conn_test.ex:443: Phoenix.ConnTest.redirected_to/2 96 | test/controllers/user_controller_test.exs:50: (test) 97 | 98 | .... 99 | 100 | 2) test creates resource and redirects when data is valid (TvRecipe.UserControllerTest) 101 | test/controllers/user_controller_test.exs:18 102 | ** (RuntimeError) expected redirection with status 302, got: 200 103 | stacktrace: 104 | (phoenix) lib/phoenix/test/conn_test.ex:443: Phoenix.ConnTest.redirected_to/2 105 | test/controllers/user_controller_test.exs:20: (test) 106 | 107 | 108 | 109 | Finished in 0.3 seconds 110 | 10 tests, 2 failures 111 | ``` 112 | 好消息是,10 个测试,有 8 个通过;坏消息是有 2 个未通过。 113 | 114 | 显然,从模板文件到现在,我们的代码已经变化,现在测试文件一样需要根据实际情况做调整: 115 | 116 | ```elixir 117 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs 118 | index 2e08483..95d3108 100644 119 | --- a/test/controllers/user_controller_test.exs 120 | +++ b/test/controllers/user_controller_test.exs 121 | @@ -2,7 +2,7 @@ defmodule TvRecipe.UserControllerTest do 122 | use TvRecipe.ConnCase 123 | 124 | alias TvRecipe.User 125 | - @valid_attrs %{email: "some content", password: "some content", username: "some content"} 126 | + @valid_attrs %{email: "chenxsan@gmail.com", password: "some content", username: "chenxsan"} 127 | @invalid_attrs %{} 128 | 129 | test "lists all entries on index", %{conn: conn} do 130 | @@ -18,7 +18,7 @@ defmodule TvRecipe.UserControllerTest do 131 | test "creates resource and redirects when data is valid", %{conn: conn} do 132 | conn = post conn, user_path(conn, :create), user: @valid_attrs 133 | assert redirected_to(conn) == user_path(conn, :index) 134 | - assert Repo.get_by(User, @valid_attrs) 135 | + assert Repo.get_by(User, @valid_attrs |> Map.delete(:password)) 136 | end 137 | 138 | test "does not create resource and renders errors when data is invalid", %{conn: conn} do 139 | @@ -48,7 +48,7 @@ defmodule TvRecipe.UserControllerTest do 140 | user = Repo.insert! %User{} 141 | conn = put conn, user_path(conn, :update, user), user: @valid_attrs 142 | assert redirected_to(conn) == user_path(conn, :show, user) 143 | - assert Repo.get_by(User, @valid_attrs) 144 | + assert Repo.get_by(User, @valid_attrs |> Map.delete(:password)) 145 | end 146 | ``` 147 | 我们在代码中做了三处修改,一个是订正 `@valid_attrs`,另外两个是修改 `Repo.get`,因为我们的 `User` 不再有 `password` 字段,所以应该从 `@valid_attrs` 中移除它,否则就会报错。 148 | 149 | 再运行测试,全部通过。 150 | 151 | 至此,我们完成所有用户注册相关的开发,下一章,开始进入[用户登录环节](../05-session/01-login.md)。 -------------------------------------------------------------------------------- /05-session/01-login.md: -------------------------------------------------------------------------------- 1 | # 登录 2 | 3 | 这一次,我们没有 `mix phoenix.gen.html` 可以用,所以要一步一步写了。 4 | 5 | 它的过程,跟[添加帮助文件一章](../02-explore-phoenix.md)一样。 6 | 7 | 但这里,我们要从测试写起,运行它,看着它抛出错误,之后才填补代码,保证每个测试通过。 8 | 9 | Don't panic,错误是指引我们成功的路灯。 10 | 11 | ## 添加路由 12 | 13 | 首先在 `test/controllers` 目录下新建一个 `session_controller_test.exs` 文件: 14 | 15 | ```elixir 16 | defmodule TvRecipe.SessionControllerTest do 17 | use TvRecipe.ConnCase 18 | end 19 | ``` 20 | 21 | 我们希望在用户访问 `/sessions/new` 网址时,返回一个登录页面。虽然目前我们还不清楚 Phoenix 下的测试代码究竟是什么原理,但没关系,我们可以参考 `user_controller_test.exs` 测试文件照猫画虎: 22 | 23 | ```elixir 24 | test "renders form for new sessions", %{conn: conn} do 25 | conn = get conn, session_path(conn, :new) 26 | # 200 响应,页面上带有“登录” 27 | assert html_response(conn, 200) =~ "登录" 28 | end 29 | ``` 30 | 运行测试,结果如下: 31 | 32 | ```bash 33 | $ mix test test/controllers/session_controller_test.exs 34 | ** (CompileError) test/controllers/session_controller_test.exs:5: undefined function session_path/2 35 | (stdlib) lists.erl:1338: :lists.foreach/2 36 | (stdlib) erl_eval.erl:670: :erl_eval.do_apply/6 37 | (elixir) lib/code.ex:370: Code.require_file/2 38 | (elixir) lib/kernel/parallel_require.ex:57: anonymous fn/2 in Kernel.ParallelRequire.spawn_requires/5 39 | ``` 40 | 41 | `session_path` 函数未定义。要怎么定义,在哪定义? 42 | 43 | 实际上,在前面的章节里,我们已经遭遇过 `user_path`,但还没有解释过它从哪里来。 44 | 45 | 我们来看 [Phoenix.Router](https://hexdocs.pm/phoenix/Phoenix.Router.html) 的文档,其中 **Helpers** 一节有说明如下: 46 | 47 | > Phoenix automatically generates a module Helpers inside your router which contains named helpers to help developers generate and keep their routes up to date. 48 | 49 | > Helpers are automatically generated based on the controller name. 50 | 51 | 我们在 `router.ex` 文件中定义 `TvRecipe.Router` 模块,而 Phoenix 会在该模块下生成一个 `TvRecipe.Router.Helpers` 模块,用于管理我们的路由。`Helpers` 下的内容,基于控制器的名称生成。 52 | 53 | 比如我们有一个路由: 54 | 55 | ```elixir 56 | get "/", PageController, :index 57 | ``` 58 | 则 Phoenix 会自动生成 `TvRecipe.Router.Helpers.page_path`。 59 | 60 | 那么,前面章节里 `user_path` 出现时,是在控制器与模板中,并且是光秃秃的 `user_path`,而不是 `TvRecipe.Router.Helpers.user_path` 这样冗长写法,它们究竟是怎样引用的? 61 | 62 | 我们回头去看控制器的代码,会在开头处看到这么一行: 63 | 64 | ```elixir 65 | use TvRecipe.Web, :controller 66 | ``` 67 | 68 | 而 `TvRecipe.Web` 是定义在 `web/web.ex` 文件,其中会有这样的内容: 69 | 70 | ```elixir 71 | def controller do 72 | quote do 73 | use Phoenix.Controller 74 | 75 | alias TvRecipe.Repo 76 | import Ecto 77 | import Ecto.Query 78 | 79 | import TvRecipe.Router.Helpers 80 | import TvRecipe.Gettext 81 | end 82 | end 83 | ``` 84 | 我们看到了 `import TvRecipe.Router.Helpers` 一行,这正是我们在控制器中可以直接使用 `user_path` 等函数的原因 - `use TvRecipe.Web, :controller` 做了准备工作。 85 | 86 | 现在,我们知道要怎么定义 `session_path` 了。 87 | 88 | 打开 `router.ex` 文件,添加一个新路由: 89 | 90 | ```elixir 91 | diff --git a/web/router.ex b/web/router.ex 92 | index 4ddc1cc..aac327c 100644 93 | --- a/web/router.ex 94 | +++ b/web/router.ex 95 | @@ -18,6 +18,7 @@ defmodule TvRecipe.Router do 96 | 97 | get "/", PageController, :index 98 | resources "/users", UserController 99 | + get "/sessions/new", SessionController, :new 100 | end 101 | ``` 102 | 运行测试: 103 | 104 | ```bash 105 | mix test test/controllers/session_controller_test.exs 106 | Compiling 8 files (.ex) 107 | 108 | 109 | 1) test renders form for new sessions (TvRecipe.SessionControllerTest) 110 | test/controllers/session_controller_test.exs:4 111 | ** (UndefinedFunctionError) function TvRecipe.SessionController.init/1 is undefined (module TvRecipe.SessionController 112 | is not available) 113 | stacktrace: 114 | TvRecipe.SessionController.init(:new) 115 | (tv_recipe) web/router.ex:1: anonymous fn/1 in TvRecipe.Router.match_route/4 116 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2 117 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2 118 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1 119 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2 120 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5 121 | test/controllers/session_controller_test.exs:5: (test) 122 | 123 | 124 | 125 | Finished in 0.08 seconds 126 | 1 test, 1 failure 127 | ``` 128 | 129 | `SessionController` 未定义。 130 | 131 | ## 创建 `SessionController` 模块 132 | 133 | 在 `web/controllers` 目录下新建一个 `session_controller.ex` 文件,内容如下: 134 | 135 | ```elixir 136 | defmodule TvRecipe.SessionController do 137 | use TvRecipe.Web, :controller 138 | 139 | def new(conn, _params) do 140 | render conn, "new.html" 141 | end 142 | end 143 | ``` 144 | 你可能在想,`_params` 是什么意思。在 Elixir 下,如果一个参数没被用到,编译时就会有提示,我们给这个未用到的参数加个 `_` 前缀,就能消除编译时的提示。 145 | 146 | 现在运行测试: 147 | 148 | ```bash 149 | mix test test/controllers/session_controller_test.exs 150 | Compiling 1 file (.ex) 151 | Generated tv_recipe app 152 | 153 | 154 | 1) test renders form for new sessions (TvRecipe.SessionControllerTest) 155 | test/controllers/session_controller_test.exs:4 156 | ** (UndefinedFunctionError) function TvRecipe.SessionView.render/2 is undefined (module TvRecipe.SessionView is not ava 157 | ilable) 158 | stacktrace: 159 | TvRecipe.SessionView.render("new.html", %{conn: %Plug.Conn{adapter: {Plug.Adapters.Test.Conn, :...}, assigns: %{layou 160 | t: {TvRecipe.LayoutView, "app.html"}}, before_send: [#Function<0.101282891/1 in Plug.CSRFProtection.call/2>, #Function<4.111 161 | 648917/1 in Phoenix.Controller.fetch_flash/2>, #Function<0.61377594/1 in Plug.Session.before_send/2>, #Function<1.115972179/ 162 | 1 in Plug.Logger.call/2>], body_params: %{}, cookies: %{}, halted: false, host: "www.example.com", method: "GET", owner: #PI 163 | D<0.302.0>, params: %{}, path_info: ["sessions", "new"], path_params: %{}, peer: {{127, 0, 0, 1}, 111317}, port: 80, private 164 | : %{TvRecipe.Router => {[], %{}}, :phoenix_action => :new, :phoenix_controller => TvRecipe.SessionController, :phoenix_endpo 165 | int => TvRecipe.Endpoint, :phoenix_flash => %{}, :phoenix_format => "html", :phoenix_layout => {TvRecipe.LayoutView, :app}, 166 | :phoenix_pipelines => [:browser], :phoenix_recycled => true, :phoenix_route => #Function<12.75217690/1 in TvRecipe.Router.ma 167 | tch_route/4>, :phoenix_router => TvRecipe.Router, :phoenix_template => "new.html", :phoenix_view => TvRecipe.SessionView, :p 168 | lug_session => %{}, :plug_session_fetch => :done, :plug_skip_csrf_protection => true}, query_params: %{}, query_string: "", 169 | remote_ip: {127, 0, 0, 1}, req_cookies: %{}, req_headers: [], request_path: "/sessions/new", resp_body: nil, resp_cookies: % 170 | {}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "eedn739jkdct1hr8r3nod6nst95b2 171 | qvu"}, {"x-frame-options", "SAMEORIGIN"}, {"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}], sch 172 | eme: :http, script_name: [], secret_key_base: "XfacEiZ/QVO87L4qirM0thXcedgcx5zYhLPAsmVPnL8AVu6qB/Et84yvJ6712aSn", state: :un 173 | set, status: nil}, view_module: TvRecipe.SessionView, view_template: "new.html"}) 174 | (tv_recipe) web/templates/layout/app.html.eex:29: TvRecipe.LayoutView."app.html"/1 175 | (phoenix) lib/phoenix/view.ex:335: Phoenix.View.render_to_iodata/3 176 | (phoenix) lib/phoenix/controller.ex:642: Phoenix.Controller.do_render/4 177 | (tv_recipe) web/controllers/session_controller.ex:1: TvRecipe.SessionController.action/2 178 | (tv_recipe) web/controllers/session_controller.ex:1: TvRecipe.SessionController.phoenix_controller_pipeline/2 179 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4 180 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2 181 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2 182 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1 183 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2 184 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5 185 | test/controllers/session_controller_test.exs:5: (test) 186 | 187 | 188 | 189 | Finished in 0.1 seconds 190 | 1 test, 1 failure 191 | ``` 192 | 测试失败,因为 `TvRecipe.SessionView` 未定义。 193 | 194 | ## 创建 `SessionView` 模块 195 | 196 | 在 `web/views` 目录下新建一个 `session_view.ex` 文件,内容如下: 197 | 198 | ```elixir 199 | defmodule TvRecipe.SessionView do 200 | use TvRecipe.Web, :view 201 | end 202 | ``` 203 | 在 Phoenix 下,View 与 templates 是分开的,其中 View 是模块(module),而 templates 在编译后,会变成 View 模块中的函数。这也是为什么我们在定义模板之前,要先定义视图的原因。 204 | 205 | 此时运行测试: 206 | 207 | ```bash 208 | mix test test/controllers/session_controller_test.exs 209 | Compiling 1 file (.ex) 210 | Generated tv_recipe app 211 | 212 | 213 | 1) test renders form for new sessions (TvRecipe.SessionControllerTest) 214 | test/controllers/session_controller_test.exs:4 215 | ** (Phoenix.Template.UndefinedError) Could not render "new.html" for TvRecipe.SessionView, please define a matching cla 216 | use for render/2 or define a template at "web/templates/session". No templates were compiled for this module. 217 | Assigns: 218 | 219 | %{conn: %Plug.Conn{adapter: {Plug.Adapters.Test.Conn, :...}, assigns: %{layout: {TvRecipe.LayoutView, "app.html"}}, bef 220 | ore_send: [#Function<0.101282891/1 in Plug.CSRFProtection.call/2>, #Function<4.111648917/1 in Phoenix.Controller.fetch_flash 221 | /2>, #Function<0.61377594/1 in Plug.Session.before_send/2>, #Function<1.115972179/1 in Plug.Logger.call/2>], body_params: %{ 222 | }, cookies: %{}, halted: false, host: "www.example.com", method: "GET", owner: #PID<0.300.0>, params: %{}, path_info: ["sess 223 | ions", "new"], path_params: %{}, peer: {{127, 0, 0, 1}, 111317}, port: 80, private: %{TvRecipe.Router => {[], %{}}, :phoenix 224 | _action => :new, :phoenix_controller => TvRecipe.SessionController, :phoenix_endpoint => TvRecipe.Endpoint, :phoenix_flash = 225 | > %{}, :phoenix_format => "html", :phoenix_layout => {TvRecipe.LayoutView, :app}, :phoenix_pipelines => [:browser], :phoenix 226 | _recycled => true, :phoenix_route => #Function<12.75217690/1 in TvRecipe.Router.match_route/4>, :phoenix_router => TvRecipe. 227 | Router, :phoenix_template => "new.html", :phoenix_view => TvRecipe.SessionView, :plug_session => %{}, :plug_session_fetch => 228 | :done, :plug_skip_csrf_protection => true}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{ 229 | }, req_headers: [], request_path: "/sessions/new", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max- 230 | age=0, private, must-revalidate"}, {"x-request-id", "vi7asqkbb9153m6ku8btf8r50p38rsqn"}, {"x-frame-options", "SAMEORIGIN"}, 231 | {"x-xss-protection", "1; mode=block"}, {"x-content-type-options", "nosniff"}], scheme: :http, script_name: [], secret_key_ba 232 | se: "XfacEiZ/QVO87L4qirM0thXcedgcx5zYhLPAsmVPnL8AVu6qB/Et84yvJ6712aSn", state: :unset, status: nil}, template_not_found: TvR 233 | ecipe.SessionView, view_module: TvRecipe.SessionView, view_template: "new.html"} 234 | 235 | stacktrace: 236 | (phoenix) lib/phoenix/template.ex:364: Phoenix.Template.raise_template_not_found/3 237 | (tv_recipe) web/templates/layout/app.html.eex:29: TvRecipe.LayoutView."app.html"/1 238 | (phoenix) lib/phoenix/view.ex:335: Phoenix.View.render_to_iodata/3 239 | (phoenix) lib/phoenix/controller.ex:642: Phoenix.Controller.do_render/4 240 | (tv_recipe) web/controllers/session_controller.ex:1: TvRecipe.SessionController.action/2 241 | (tv_recipe) web/controllers/session_controller.ex:1: TvRecipe.SessionController.phoenix_controller_pipeline/2 242 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4 243 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2 244 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2 245 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1 246 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2 247 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5 248 | test/controllers/session_controller_test.exs:5: (test) 249 | 250 | 251 | 252 | Finished in 0.1 seconds 253 | 1 test, 1 failure 254 | ``` 255 | 测试失败,因为 new.html 模板不存在。 256 | 257 | ## 创建 `new.html.eex` 模板文件 258 | 259 | 在 `web/templates/session` 目录中新建一个空白 `new.html.eex` 模板文件。 260 | 261 | 现在运行测试: 262 | 263 | ```bash 264 | mix test test/controllers/session_controller_test.exs 265 | Compiling 1 file (.ex) 266 | 267 | 268 | 1) test renders form for new sessions (TvRecipe.SessionControllerTest) 269 | test/controllers/session_controller_test.exs:4 270 | Assertion with =~ failed 271 | code: html_response(conn, 200) =~ "登录" 272 | left: "\n\n \n \n \n \n \n \n\n Hello TvRecipe!\n \n \n\n \n
\n
\n 276 | \n \n 278 |
\n\n

\n

\n\n
\n
\n\n
\n \n \n\n" 34 | right: "退出" 35 | stacktrace: 36 | test/controllers/session_controller_test.exs:30: (test) 37 | 38 | .... 39 | 40 | Finished in 0.4 seconds 41 | 35 tests, 1 failure 42 | ``` 43 | 44 | 打开 `app.html.eex` 文件,做如下修改: 45 | 46 | ```elixir 47 | diff --git a/web/templates/layout/app.html.eex b/web/templates/layout/app.html.eex 48 | index 2d39904..6c87a08 100644 49 | --- a/web/templates/layout/app.html.eex 50 | +++ b/web/templates/layout/app.html.eex 51 | @@ -19,6 +19,7 @@ 52 |
  • Get Started
  • 53 | <%= if @current_user do %> 54 |
  • <%= link @current_user.username, to: user_path(@conn, :show, @current_user) %>
  • 55 | +
  • <%= link "退出", to: session_path(@conn, :delete, @current_user), method: "delete" %>
  • 56 | <% end %> 57 | 58 | 59 | ``` 60 | 现在运行测试: 61 | 62 | ```bash 63 | $ mix test 64 | ............................. 65 | 66 | 1) test creates resource and redirects when data is valid (TvRecipe.UserControllerTest) 67 | test/controllers/user_controller_test.exs:18 68 | ** (ArgumentError) No helper clause for TvRecipe.Router.Helpers.session_path/3 defined for action :delete. 69 | The following session_path actions are defined under your router: 70 | 71 | * :create 72 | * :new 73 | stacktrace: 74 | (phoenix) lib/phoenix/router/helpers.ex:269: Phoenix.Router.Helpers.raise_route_error/5 75 | (tv_recipe) web/templates/layout/app.html.eex:22: TvRecipe.LayoutView."app.html"/1 76 | (phoenix) lib/phoenix/view.ex:335: Phoenix.View.render_to_iodata/3 77 | (phoenix) lib/phoenix/controller.ex:642: Phoenix.Controller.do_render/4 78 | (tv_recipe) web/controllers/page_controller.ex:1: TvRecipe.PageController.action/2 79 | (tv_recipe) web/controllers/page_controller.ex:1: TvRecipe.PageController.phoenix_controller_pipeline/2 80 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4 81 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2 82 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2 83 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1 84 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2 85 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5 86 | test/controllers/user_controller_test.exs:23: (test) 87 | 88 | ... 89 | 90 | 2) test login user and redirect to home page when data is valid (TvRecipe.SessionControllerTest) 91 | test/controllers/session_controller_test.exs:13 92 | ** (ArgumentError) No helper clause for TvRecipe.Router.Helpers.session_path/3 defined for action :delete. 93 | The following session_path actions are defined under your router: 94 | 95 | * :create 96 | * :new 97 | stacktrace: 98 | (phoenix) lib/phoenix/router/helpers.ex:269: Phoenix.Router.Helpers.raise_route_error/5 99 | (tv_recipe) web/templates/layout/app.html.eex:22: TvRecipe.LayoutView."app.html"/1 100 | (phoenix) lib/phoenix/view.ex:335: Phoenix.View.render_to_iodata/3 101 | (phoenix) lib/phoenix/controller.ex:642: Phoenix.Controller.do_render/4 102 | (tv_recipe) web/controllers/page_controller.ex:1: TvRecipe.PageController.action/2 103 | (tv_recipe) web/controllers/page_controller.ex:1: TvRecipe.PageController.phoenix_controller_pipeline/2 104 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4 105 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2 106 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2 107 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1 108 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2 109 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5 110 | test/controllers/session_controller_test.exs:24: (test) 111 | 112 | . 113 | 114 | Finished in 0.5 seconds 115 | 35 tests, 2 failures 116 | ``` 117 | 前一个测试中的错误消除了,却又多了两个错误。这是因为我们还没有定义 `session_controller.ex` 文件中的 `delete` 动作及相应路由。 118 | 119 | 我们希望用户登录后点击“退出”,页面跳转到主页,并显示“退出成功”。 120 | 121 | 我们的测试这么写: 122 | 123 | ```elixir 124 | diff --git a/test/controllers/session_controller_test.exs b/test/controllers/session_controller_test.exs 125 | index 511d0ab..969662a 100644 126 | --- a/test/controllers/session_controller_test.exs 127 | +++ b/test/controllers/session_controller_test.exs 128 | @@ -50,4 +50,18 @@ defmodule TvRecipe.SessionControllerTest do 129 | assert html_response(conn, 200) =~ "登录" 130 | end 131 | 132 | + test "logouts user when logout button clicked", %{conn: conn} do 133 | + # 在数据库中新建一个用户 134 | + changeset = User.changeset(%User{}, @valid_user_attrs) 135 | + user = Repo.insert!(changeset) 136 | + 137 | + # 登录该用户 138 | + conn = post conn, session_path(conn, :create), session: Map.delete(@valid_user_attrs, :username) 139 | + 140 | + # 点击退出 141 | + conn = delete conn, session_path(conn, :delete, user) 142 | + assert get_flash(conn, :info) == "退出成功" 143 | + assert redirected_to(conn) == page_path(conn, :index) 144 | + end 145 | + 146 | end 147 | ``` 148 | 接着我们根据测试中的要求调整 `session_controller.ex` 文件及 `router.ex`: 149 | 150 | ```elixir 151 | diff --git a/web/controllers/session_controller.ex b/web/controllers/session_controller.ex 152 | index b5218f2..2a887ee 100644 153 | --- a/web/controllers/session_controller.ex 154 | +++ b/web/controllers/session_controller.ex 155 | @@ -30,4 +30,11 @@ defmodule TvRecipe.SessionController do 156 | |> render("new.html") 157 | end 158 | end 159 | + 160 | + def delete(conn, _params) do 161 | + conn 162 | + |> delete_session(:user_id) 163 | + |> put_flash(:info, "退出成功") 164 | + |> redirect(to: page_path(conn, :index)) 165 | + end 166 | end 167 | 168 | diff --git a/web/router.ex b/web/router.ex 169 | index 1265c86..4c12197 100644 170 | --- a/web/router.ex 171 | +++ b/web/router.ex 172 | @@ -21,6 +21,7 @@ defmodule TvRecipe.Router do 173 | resources "/users", UserController 174 | get "/sessions/new", SessionController, :new 175 | post "/sessions/new", SessionController, :create 176 | + delete "/sessions/:id", SessionController, :delete 177 | end 178 | 179 | # Other scopes may use custom stacks. 180 | ``` 181 | 运行测试,全部通过。 182 | 183 | 我们还可以优化下 `router.ex` 文件: 184 | 185 | ```elixir 186 | diff --git a/web/router.ex b/web/router.ex 187 | index 4c12197..292aeb8 100644 188 | --- a/web/router.ex 189 | +++ b/web/router.ex 190 | @@ -19,9 +19,7 @@ defmodule TvRecipe.Router do 191 | 192 | get "/", PageController, :index 193 | resources "/users", UserController 194 | - get "/sessions/new", SessionController, :new 195 | - post "/sessions/new", SessionController, :create 196 | - delete "/sessions/:id", SessionController, :delete 197 | + resources "/sessions", SessionController, only: [:new, :create, :delete] 198 | end 199 | 200 | # Other scopes may use custom stacks. 201 | ``` 202 | 203 | 下一章,我们给页面加上[登录/注册按钮](04-login-logout-buttons.md),方便用户操作。 -------------------------------------------------------------------------------- /05-session/04-login-logout-buttons.md: -------------------------------------------------------------------------------- 1 | # 登录/注册按钮 2 | 3 | 我们至今还没有在页面上添加登录/注册按钮,这对普通用户来说非常不友好。 4 | 5 | 老规则,先写测试,我们不再新增,而是加在旧测试中: 6 | 7 | ```elixir 8 | diff --git a/test/controllers/session_controller_test.exs b/test/controllers/session_controller_test.exs 9 | index 969662a..98fbb5a 100644 10 | --- a/test/controllers/session_controller_test.exs 11 | +++ b/test/controllers/session_controller_test.exs 12 | @@ -14,6 +14,10 @@ defmodule TvRecipe.SessionControllerTest do 13 | user_changeset = User.changeset(%User{}, @valid_user_attrs) 14 | # 插入新用户 15 | user = Repo.insert! user_changeset 16 | + # 未登录情况下访问首页,应带有登录/注册文字 17 | + conn = get conn, page_path(conn, :index) 18 | + assert html_response(conn, 200) =~ "登录" 19 | + assert html_response(conn, 200) =~ "注册" 20 | # 用户登录 21 | conn = post conn, session_path(conn, :create), session: @valid_user_attrs 22 | # 显示“欢迎你”的消息 23 | ``` 24 | 25 | 打开 `app.html.eex` 文件,添加两个按钮: 26 | 27 | ```eex 28 | diff --git a/web/templates/layout/app.html.eex b/web/templates/layout/app.html.eex 29 | index 6c87a08..b13f370 100644 30 | --- a/web/templates/layout/app.html.eex 31 | +++ b/web/templates/layout/app.html.eex 32 | @@ -20,6 +20,9 @@ 33 | <%= if @current_user do %> 34 |
  • <%= link @current_user.username, to: user_path(@conn, :show, @current_user) %>
  • 35 |
  • <%= link "退出", to: session_path(@conn, :delete, @current_user), method: "delete" %>
  • 36 | + <% else %> 37 | +
  • <%= link "登录", to: session_path(@conn, :new) %>
  • 38 | +
  • <%= link "注册", to: user_path(@conn, :new) %>
  • 39 | <% end %> 40 | 41 | 42 | ``` 43 | 运行测试: 44 | 45 | ```bash 46 | mix test 47 | .................................... 48 | 49 | Finished in 0.8 seconds 50 | 36 tests, 0 failures 51 | ``` 52 | 全部通过。 53 | 54 | 在进入下一章前,我们还有个安全相关的问题需要略作修改: 55 | 56 | ```elixir 57 | diff --git a/web/controllers/session_controller.ex b/web/controllers/session_controller.ex 58 | index 6ac524c..0c2eb0a 100644 59 | --- a/web/controllers/session_controller.ex 60 | +++ b/web/controllers/session_controller.ex 61 | @@ -15,6 +15,7 @@ defmodule TvRecipe.SessionController do 62 | conn 63 | |> put_session(:user_id, user.id) 64 | |> put_flash(:info, "欢迎你") 65 | + |> configure_session(renew: true) 66 | |> redirect(to: page_path(conn, :index)) 67 | # 用户存在,但密码错误 68 | user -> 69 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex 70 | index 8d8a6f5..8b9b38b 100644 71 | --- a/web/controllers/user_controller.ex 72 | +++ b/web/controllers/user_controller.ex 73 | @@ -21,6 +21,7 @@ defmodule TvRecipe.UserController do 74 | conn 75 | |> put_flash(:info, "User created successfully.") 76 | |> put_session(:user_id, user.id) 77 | + |> configure_session(renew: true) 78 | |> redirect(to: page_path(conn, :index)) 79 | {:error, changeset} -> 80 | render(conn, "new.html", changeset: changeset) 81 | ``` 82 | 我们在 `session_controller.ex` 与 `user_controller.ex` 两个文件中加入了 `configure_session(renew: true)`,用于预防 [session fixation 攻击](https://www.owasp.org/index.php/Session_fixation)。 83 | 84 | 另外,本着 DRY 的原则,我们可以将登录的逻辑合并到 `auth.ex` 文件中: 85 | 86 | ```elixir 87 | diff --git a/web/controllers/auth.ex b/web/controllers/auth.ex 88 | index 994112d..e298b68 100644 89 | --- a/web/controllers/auth.ex 90 | +++ b/web/controllers/auth.ex 91 | @@ -15,4 +15,10 @@ defmodule TvRecipe.Auth do 92 | assign(conn, :current_user, user) 93 | end 94 | 95 | + def login(conn, user) do 96 | + conn 97 | + |> put_session(:user_id, user.id) 98 | + |> configure_session(renew: true) 99 | + end 100 | + 101 | end 102 | 103 | diff --git a/web/controllers/session_controller.ex b/web/controllers/session_controller.ex 104 | index 0c2eb0a..6f29ce0 100644 105 | --- a/web/controllers/session_controller.ex 106 | +++ b/web/controllers/session_controller.ex 107 | @@ -13,9 +13,8 @@ defmodule TvRecipe.SessionController do 108 | # 用户存在,且密码正确 109 | user && Comeonin.Bcrypt.checkpw(password, user.password_hash) -> 110 | conn 111 | - |> put_session(:user_id, user.id) 112 | |> put_flash(:info, "欢迎你") 113 | - |> configure_session(renew: true) 114 | + |> TvRecipe.Auth.login(user) 115 | |> redirect(to: page_path(conn, :index)) 116 | # 用户存在,但密码错误 117 | user -> 118 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex 119 | index 8b9b38b..b9234b1 100644 120 | --- a/web/controllers/user_controller.ex 121 | +++ b/web/controllers/user_controller.ex 122 | @@ -20,8 +20,7 @@ defmodule TvRecipe.UserController do 123 | {:ok, user} -> 124 | conn 125 | |> put_flash(:info, "User created successfully.") 126 | - |> put_session(:user_id, user.id) 127 | - |> configure_session(renew: true) 128 | + |> TvRecipe.Auth.login(user) 129 | |> redirect(to: page_path(conn, :index)) 130 | {:error, changeset} -> 131 | render(conn, "new.html", changeset: changeset) 132 | ``` 133 | 134 | 下一章,我们要对[用户相关页面做些限制](../06-restrict-access.md),以保证数据的安全。 -------------------------------------------------------------------------------- /06-restrict-access.md: -------------------------------------------------------------------------------- 1 | # 安全限制 2 | 3 | ## 限制未登录用户访问用户页面 4 | 5 | 目前为止,所有未登录用户都可以访问、操作用户相关页面。我们要加以限制:**未登录用户只允许使用 `:new` 及 `:create` 两个动作,访问其余动作时,全部重定向到登录页。** 6 | 7 | 首先在 `user_controller_test.exs` 文件中新增一个测试,具体内容看注释: 8 | 9 | ```elixir 10 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs 11 | index 26055e3..ac6894e 100644 12 | --- a/test/controllers/user_controller_test.exs 13 | +++ b/test/controllers/user_controller_test.exs 14 | @@ -66,4 +66,18 @@ defmodule TvRecipe.UserControllerTest do 15 | assert redirected_to(conn) == user_path(conn, :index) 16 | refute Repo.get(User, user.id) 17 | end 18 | + 19 | + test "guest access user action redirected to login page", %{conn: conn} do 20 | + user = Repo.insert! %User{} 21 | + Enum.each([ 22 | + get(conn, user_path(conn, :index)), 23 | + get(conn, user_path(conn, :show, user)), 24 | + get(conn, user_path(conn, :edit, user)), 25 | + put(conn, user_path(conn, :update, user), user: %{}), 26 | + delete(conn, user_path(conn, :delete, user)) 27 | + ], fn conn -> 28 | + assert redirected_to(conn) == session_path(conn, :new) 29 | + assert conn.halted 30 | + end) 31 | + end 32 | end 33 | ``` 34 | 接下来修改 `user_controller.ex` 文件中的代码: 35 | 36 | ```elixir 37 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex 38 | index b9234b1..7bb7dac 100644 39 | --- a/web/controllers/user_controller.ex 40 | +++ b/web/controllers/user_controller.ex 41 | @@ -1,5 +1,6 @@ 42 | defmodule TvRecipe.UserController do 43 | use TvRecipe.Web, :controller 44 | + plug :login_require when action in [:index, :show, :edit, :update, :delete] 45 | 46 | alias TvRecipe.User 47 | 48 | @@ -63,4 +64,20 @@ defmodule TvRecipe.UserController do 49 | |> put_flash(:info, "User deleted successfully.") 50 | |> redirect(to: user_path(conn, :index)) 51 | end 52 | + 53 | + @doc """ 54 | + 检查用户登录状态 55 | + 56 | + Returns `conn` 57 | + """ 58 | + def login_require(conn, _opts) do 59 | + if conn.assigns.current_user do 60 | + conn 61 | + else 62 | + conn 63 | + |> put_flash(:info, "请先登录") 64 | + |> redirect(to: session_path(conn, :new)) 65 | + |> halt() 66 | + end 67 | + end 68 | end 69 | ``` 70 | 我们增加了一个函数式 plug `login_require`,并且将它应用在控制器中的动作前。 71 | 72 | 还记得我们的一个流程图吗? 73 | 74 | ```elixir 75 | conn 76 | |> router 77 | |> pipelines 78 | |> controller 79 | |> view 80 | |> template 81 | ``` 82 | 这里,我们还能再进一步完善它: 83 | 84 | ```elixir 85 | conn 86 | |> router 87 | |> pipelines 88 | |> controller 89 | |> plugs 90 | |> action 91 | |> view 92 | |> template 93 | ``` 94 | 我们的控制器在执行动作前,会按指定顺序执行一系列 plug。 95 | 96 | 现在运行测试: 97 | 98 | ```bash 99 | $ mix test 100 | ..................... 101 | 102 | 1) test renders form for editing chosen resource (TvRecipe.UserControllerTest) 103 | test/controllers/user_controller_test.exs:44 104 | ** (RuntimeError) expected response with status 200, got: 302, with body: 105 | You are being redirected. 106 | stacktrace: 107 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2 108 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2 109 | test/controllers/user_controller_test.exs:47: (test) 110 | 111 | 112 | 113 | 2) test lists all entries on index (TvRecipe.UserControllerTest) 114 | test/controllers/user_controller_test.exs:8 115 | ** (RuntimeError) expected response with status 200, got: 302, with body: 116 | You are being redirected. 117 | stacktrace: 118 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2 119 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2 120 | test/controllers/user_controller_test.exs:10: (test) 121 | 122 | 123 | 124 | 3) test renders page not found when id is nonexistent (TvRecipe.UserControllerTest) 125 | test/controllers/user_controller_test.exs:38 126 | expected error to be sent as 404 status, but response sent 302 without error 127 | stacktrace: 128 | (phoenix) lib/phoenix/test/conn_test.ex:570: Phoenix.ConnTest.assert_error_sent/2 129 | test/controllers/user_controller_test.exs:39: (test) 130 | 131 | .. 132 | 133 | 4) test deletes chosen resource (TvRecipe.UserControllerTest) 134 | test/controllers/user_controller_test.exs:63 135 | Assertion with == failed 136 | code: redirected_to(conn) == user_path(conn, :index) 137 | left: "/sessions/new" 138 | right: "/users" 139 | stacktrace: 140 | test/controllers/user_controller_test.exs:66: (test) 141 | 142 | 143 | 144 | 5) test shows chosen resource (TvRecipe.UserControllerTest) 145 | test/controllers/user_controller_test.exs:32 146 | ** (RuntimeError) expected response with status 200, got: 302, with body: 147 | You are being redirected. 148 | stacktrace: 149 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2 150 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2 151 | test/controllers/user_controller_test.exs:35: (test) 152 | 153 | 154 | 155 | 6) test updates chosen resource and redirects when data is valid (TvRecipe.UserControllerTest) 156 | test/controllers/user_controller_test.exs:50 157 | Assertion with == failed 158 | code: redirected_to(conn) == user_path(conn, :show, user) 159 | left: "/sessions/new" 160 | right: "/users/1121" 161 | stacktrace: 162 | test/controllers/user_controller_test.exs:53: (test) 163 | 164 | 165 | 166 | 7) test does not update chosen resource and renders errors when data is invalid (TvRecipe.UserControllerTest) 167 | test/controllers/user_controller_test.exs:57 168 | ** (RuntimeError) expected response with status 200, got: 302, with body: 169 | You are being redirected. 170 | stacktrace: 171 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2 172 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2 173 | test/controllers/user_controller_test.exs:60: (test) 174 | 175 | ....... 176 | 177 | Finished in 0.4 seconds 178 | 37 tests, 7 failures 179 | ``` 180 | 因为我们前面新增了 `login_require` 限制,导致旧的测试有 7 个失败,它们均需要用户登录。 181 | 182 | 怎么测试用户登录的情况? 183 | 184 | 我们有一种选择是,在每一个测试前登录用户,比如这样: 185 | 186 | ```elixir 187 | test "shows chosen resource", %{conn: conn} do 188 | user = Repo.insert! User.changeset(%User{}, @valid_attrs) 189 | conn = post conn, session_path(conn, :create), session: @valid_attrs # <= 这一行,登录用户 190 | conn = get conn, user_path(conn, :show, user) 191 | assert html_response(conn, 200) =~ "Show user" 192 | end 193 | ``` 194 | 只是这样我们会重复很多代码。 195 | 196 | 我们还可以借助 [`setup`](https://hexdocs.pm/ex_unit/ExUnit.Callbacks.html#setup/2)。在 Elixir 的测试里,`setup` 块的代码会在每一个 `test` 执行以前执行,它们返回的内容合并进 `context`,然后我们就可以在 `test` 中获取到。 197 | 198 | 但我们在一个测试文件中涉及两种情况,登录与未登录,`setup` 要如何区分它们? 199 | 200 | 我们可以使用 [`tag`](https://hexdocs.pm/ex_unit/ExUnit.Case.html#module-tags)。通过 `tag`,我们在上下文 `context` 中存储变量,`setup` 读取 `context`,根据需求返回不同的数据。 201 | 202 | 我们的代码改造如下: 203 | 204 | ```elixir 205 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs 206 | index ac6894e..e11df40 100644 207 | --- a/test/controllers/user_controller_test.exs 208 | +++ b/test/controllers/user_controller_test.exs 209 | @@ -1,10 +1,22 @@ 210 | defmodule TvRecipe.UserControllerTest do 211 | use TvRecipe.ConnCase 212 | 213 | - alias TvRecipe.User 214 | + alias TvRecipe.{Repo, User} 215 | @valid_attrs %{email: "chenxsan@gmail.com", password: "some content", username: "chenxsan"} 216 | @invalid_attrs %{} 217 | 218 | + setup %{conn: conn} = context do 219 | + if context[:logged_in] == true do 220 | + # 如果上下文里 :logged_in 值为 true 221 | + user = Repo.insert! User.changeset(%User{}, @valid_attrs) 222 | + conn = post conn, session_path(conn, :create), session: @valid_attrs 223 | + {:ok, [conn: conn, user: user]} 224 | + else 225 | + :ok 226 | + end 227 | + end 228 | + 229 | + @tag logged_in: true 230 | test "lists all entries on index", %{conn: conn} do 231 | conn = get conn, user_path(conn, :index) 232 | assert html_response(conn, 200) =~ "Listing users" 233 | @@ -29,24 +41,28 @@ defmodule TvRecipe.UserControllerTest do 234 | assert html_response(conn, 200) =~ "New user" 235 | end 236 | 237 | + @tag logged_in: true 238 | test "shows chosen resource", %{conn: conn} do 239 | user = Repo.insert! %User{} 240 | conn = get conn, user_path(conn, :show, user) 241 | assert html_response(conn, 200) =~ "Show user" 242 | end 243 | 244 | + @tag logged_in: true 245 | test "renders page not found when id is nonexistent", %{conn: conn} do 246 | assert_error_sent 404, fn -> 247 | get conn, user_path(conn, :show, -1) 248 | end 249 | end 250 | 251 | + @tag logged_in: true 252 | test "renders form for editing chosen resource", %{conn: conn} do 253 | user = Repo.insert! %User{} 254 | conn = get conn, user_path(conn, :edit, user) 255 | assert html_response(conn, 200) =~ "Edit user" 256 | end 257 | 258 | + @tag logged_in: true 259 | test "updates chosen resource and redirects when data is valid", %{conn: conn} do 260 | user = Repo.insert! %User{} 261 | conn = put conn, user_path(conn, :update, user), user: @valid_attrs 262 | @@ -54,12 +70,14 @@ defmodule TvRecipe.UserControllerTest do 263 | assert Repo.get_by(User, @valid_attrs |> Map.delete(:password)) 264 | end 265 | 266 | + @tag logged_in: true 267 | test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do 268 | user = Repo.insert! %User{} 269 | conn = put conn, user_path(conn, :update, user), user: @invalid_attrs 270 | assert html_response(conn, 200) =~ "Edit user" 271 | end 272 | 273 | + @tag logged_in: true 274 | test "deletes chosen resource", %{conn: conn} do 275 | user = Repo.insert! %User{} 276 | conn = delete conn, user_path(conn, :delete, user) 277 | ``` 278 | 我们根据 `logged_in` 的值返回不同 `conn`:一个是用户登录的 conn,一个是未登录的 conn。 279 | 280 | 现在运行测试: 281 | 282 | ```bash 283 | $ mix test 284 | ........................... 285 | 286 | 1) test updates chosen resource and redirects when data is valid (TvRecipe.UserControllerTest) 287 | test/controllers/user_controller_test.exs:66 288 | ** (RuntimeError) expected redirection with status 302, got: 200 289 | stacktrace: 290 | (phoenix) lib/phoenix/test/conn_test.ex:443: Phoenix.ConnTest.redirected_to/2 291 | test/controllers/user_controller_test.exs:69: (test) 292 | 293 | ......... 294 | 295 | Finished in 0.5 seconds 296 | 37 tests, 1 failure 297 | ``` 298 | 我们修复了大部分的错误,但还有一个失败的。 299 | 300 | 检查测试代码我们可以发现,`setup` 块里创建了一个邮箱为 `chenxsan@gmail.com`、用户名为 `chenxsan` 的用户,而更新时邮箱与用户名重复了。 301 | 302 | 我们调整一下: 303 | 304 | ```elixir 305 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs 306 | index e11df40..c8263c6 100644 307 | --- a/test/controllers/user_controller_test.exs 308 | +++ b/test/controllers/user_controller_test.exs 309 | @@ -65,7 +65,7 @@ defmodule TvRecipe.UserControllerTest do 310 | @tag logged_in: true 311 | test "updates chosen resource and redirects when data is valid", %{conn: conn} do 312 | user = Repo.insert! %User{} 313 | - conn = put conn, user_path(conn, :update, user), user: @valid_attrs 314 | + conn = put conn, user_path(conn, :update, user), user: %{@valid_attrs | username: "samchen", email: "chenxsan+1@gmail.com"} 315 | assert redirected_to(conn) == user_path(conn, :show, user) 316 | assert Repo.get_by(User, @valid_attrs |> Map.delete(:password)) 317 | end 318 | ``` 319 | 这样,我们就修正了所有测试。 320 | 321 | ## 限制用户访问管理动作 322 | 323 | `user_controller.ex` 文件中,`:index` 与 `:delete` 动作通常是管理员才允许使用的,对普通用户来说,它们应该不可见。 324 | 325 | 我们直接移除相应的路由与控制器动作: 326 | 327 | ```elixir 328 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex 329 | index 7bb7dac..c0056fd 100644 330 | --- a/web/controllers/user_controller.ex 331 | --- a/web/controllers/user_controller.ex 332 | +++ b/web/controllers/user_controller.ex 333 | @@ -1,14 +1,9 @@ 334 | defmodule TvRecipe.UserController do 335 | use TvRecipe.Web, :controller 336 | - plug :login_require when action in [:index, :show, :edit, :update, :delete] 337 | + plug :login_require when action in [:show, :edit, :update] 338 | 339 | alias TvRecipe.User 340 | 341 | - def index(conn, _params) do 342 | - users = Repo.all(User) 343 | - render(conn, "index.html", users: users) 344 | - end 345 | - 346 | def new(conn, _params) do 347 | changeset = User.changeset(%User{}) 348 | render(conn, "new.html", changeset: changeset) 349 | @@ -53,18 +48,6 @@ defmodule TvRecipe.UserController do 350 | end 351 | end 352 | 353 | - def delete(conn, %{"id" => id}) do 354 | - user = Repo.get!(User, id) 355 | - 356 | - # Here we use delete! (with a bang) because we expect 357 | - # it to always work (and if it does not, it will raise). 358 | - Repo.delete!(user) 359 | - 360 | - conn 361 | - |> put_flash(:info, "User deleted successfully.") 362 | - |> redirect(to: user_path(conn, :index)) 363 | - end 364 | - 365 | @doc """ 366 | 检查用户登录状态 367 | ``` 368 | 然后运行测试。测试会帮我们定位出所有需要移除或修正的代码,我们逐一修改如下: 369 | 370 | ```elixir 371 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs 372 | index c8263c6..a2ccee0 100644 373 | --- a/test/controllers/user_controller_test.exs 374 | +++ b/test/controllers/user_controller_test.exs 375 | @@ -16,12 +16,6 @@ defmodule TvRecipe.UserControllerTest do 376 | end 377 | end 378 | 379 | - @tag logged_in: true 380 | - test "lists all entries on index", %{conn: conn} do 381 | - conn = get conn, user_path(conn, :index) 382 | - assert html_response(conn, 200) =~ "Listing users" 383 | - end 384 | end 385 | end 386 | 387 | - @tag logged_in: true 388 | - test "lists all entries on index", %{conn: conn} do 389 | - conn = get conn, user_path(conn, :index) 390 | - assert html_response(conn, 200) =~ "Listing users" 391 | - end 392 | - 393 | test "renders form for new resources", %{conn: conn} do 394 | conn = get conn, user_path(conn, :new) 395 | assert html_response(conn, 200) =~ "New user" 396 | @@ -77,22 +71,12 @@ defmodule TvRecipe.UserControllerTest do 397 | assert html_response(conn, 200) =~ "Edit user" 398 | end 399 | 400 | - @tag logged_in: true 401 | - test "deletes chosen resource", %{conn: conn} do 402 | - user = Repo.insert! %User{} 403 | - conn = delete conn, user_path(conn, :delete, user) 404 | - assert redirected_to(conn) == user_path(conn, :index) 405 | - refute Repo.get(User, user.id) 406 | - end 407 | - 408 | test "guest access user action redirected to login page", %{conn: conn} do 409 | user = Repo.insert! %User{} 410 | Enum.each([ 411 | - get(conn, user_path(conn, :index)), 412 | get(conn, user_path(conn, :show, user)), 413 | get(conn, user_path(conn, :edit, user)), 414 | put(conn, user_path(conn, :update, user), user: %{}), 415 | - delete(conn, user_path(conn, :delete, user)) 416 | ], fn conn -> 417 | assert redirected_to(conn) == session_path(conn, :new) 418 | assert conn.halted 419 | ], fn conn -> 420 | assert redirected_to(conn) == session_path(conn, :new) 421 | assert conn.halted 422 | diff --git a/web/templates/user/edit.html.eex b/web/templates/user/edit.html.eex 423 | index 7e08f2b..beae173 100644 424 | --- a/web/templates/user/edit.html.eex 425 | +++ b/web/templates/user/edit.html.eex 426 | @@ -2,5 +2,3 @@ 427 | 428 | <%= render "form.html", changeset: @changeset, 429 | action: user_path(@conn, :update, @user) %> 430 | - 431 | -<%= link "Back", to: user_path(@conn, :index) %> 432 | diff --git a/web/templates/user/new.html.eex b/web/templates/user/new.html.eex 433 | index e0b494f..adf2399 100644 434 | --- a/web/templates/user/new.html.eex 435 | +++ b/web/templates/user/new.html.eex 436 | @@ -2,5 +2,3 @@ 437 | 438 | <%= render "form.html", changeset: @changeset, 439 | action: user_path(@conn, :create) %> 440 | - 441 | -<%= link "Back", to: user_path(@conn, :index) %> 442 | diff --git a/web/templates/user/show.html.eex b/web/templates/user/show.html.eex 443 | index d05f88d..4c3f497 100644 444 | --- a/web/templates/user/show.html.eex 445 | +++ b/web/templates/user/show.html.eex 446 | @@ -20,4 +20,3 @@ 447 | 448 | 449 | <%= link "Edit", to: user_path(@conn, :edit, @user) %> 450 | -<%= link "Back", to: user_path(@conn, :index) %> 451 | ``` 452 | 再次运行测试,全部通过。 453 | 454 | ## 限制已登录用户访问他人页面 455 | 456 | 我们还有一个问题,就是登录后的用户,通过修改 url 地址,能够访问他人的用户页面,还可以修改他人的信息。 457 | 458 | 我们需要加以限制:只有用户自己才可以访问、修改自己的用户页面。 459 | 460 | 我们有几种解决办法: 461 | 462 | 1. 不再通过 id 获取用户,直接读取 `conn.assigns.current_user`。 463 | 2. 把 id 隐藏起来,改用 `/profile` 这样的路径,用户就无从修改 url 中的 id,不过我们也没办法从 url 中获取 id,只能读取 `conn.assigns.current_user`。 464 | 3. 定义一个 `plug`,检查用户访问的 id 与 `conn.assigns.current_user` 的 id 是否一致,不一致则跳转。 465 | 466 | 这里使用第三种办法。 467 | 468 | 先定义一个测试: 469 | 470 | ```elixir 471 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs 472 | index a2ccee0..fd57531 100644 473 | --- a/test/controllers/user_controller_test.exs 474 | +++ b/test/controllers/user_controller_test.exs 475 | @@ -82,4 +82,19 @@ defmodule TvRecipe.UserControllerTest do 476 | assert conn.halted 477 | end) 478 | end 479 | + 480 | + @tag logged_in: true 481 | + test "does not allow access to other user path", %{conn: conn, user: user} do 482 | + another_user = Repo.insert! %User{} 483 | + Enum.each([ 484 | + get(conn, user_path(conn, :show, another_user)), 485 | + get(conn, user_path(conn, :edit, another_user)), 486 | + put(conn, user_path(conn, :update, another_user), user: %{}) 487 | + ], fn conn -> 488 | + assert get_flash(conn, :error) == "禁止访问未授权页面" 489 | + assert redirected_to(conn) == user_path(conn, :show, user) 490 | + assert conn.halted 491 | + end) 492 | + end 493 | + 494 | end 495 | ``` 496 | 然后修改 `user_controller.ex` 文件: 497 | 498 | ```elixir 499 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex 500 | index c0056fd..520d986 100644 501 | --- a/web/controllers/user_controller.ex 502 | +++ b/web/controllers/user_controller.ex 503 | @@ -1,6 +1,7 @@ 504 | defmodule TvRecipe.UserController do 505 | use TvRecipe.Web, :controller 506 | plug :login_require when action in [:show, :edit, :update] 507 | + plug :self_require when action in [:show, :edit, :update] 508 | 509 | alias TvRecipe.User 510 | 511 | @@ -63,4 +64,21 @@ defmodule TvRecipe.UserController do 512 | |> halt() 513 | end 514 | end 515 | + 516 | + @doc """ 517 | + 检查用户是否授权访问动作 518 | + 519 | + Returns `conn` 520 | + """ 521 | + def self_require(conn, _opts) do 522 | + %{"id" => id} = conn.params 523 | + if String.to_integer(id) == conn.assigns.current_user.id do 524 | + conn 525 | + else 526 | + conn 527 | + |> put_flash(:error, "禁止访问未授权页面") 528 | + |> redirect(to: user_path(conn, :show, conn.assigns.current_user)) 529 | + |> halt() 530 | + end 531 | + end 532 | end 533 | ``` 534 | 我们增加了一个 `self_require` 的 plug,并应用到几个动作上。请注意两个 plug 的顺序,`self_require` 排在 `login_require` 后面。 535 | 536 | 执行测试: 537 | 538 | ```bash 539 | $ mix test 540 | .................... 541 | 542 | 1) test shows chosen resource (TvRecipe.UserControllerTest) 543 | test/controllers/user_controller_test.exs:39 544 | ** (RuntimeError) expected response with status 200, got: 302, with body: 545 | You are being redirected. 546 | stacktrace: 547 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2 548 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2 549 | test/controllers/user_controller_test.exs:42: (test) 550 | 551 | 552 | 553 | 2) test renders form for editing chosen resource (TvRecipe.UserControllerTest) 554 | test/controllers/user_controller_test.exs:53 555 | ** (RuntimeError) expected response with status 200, got: 302, with body: 556 | You are being redirected. 557 | stacktrace: 558 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2 559 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2 560 | test/controllers/user_controller_test.exs:56: (test) 561 | 562 | .... 563 | 564 | 3) test updates chosen resource and redirects when data is valid (TvRecipe.UserControllerTest) 565 | test/controllers/user_controller_test.exs:60 566 | Assertion with == failed 567 | code: redirected_to(conn) == user_path(conn, :show, user) 568 | left: "/users/2948" 569 | right: "/users/2949" 570 | stacktrace: 571 | test/controllers/user_controller_test.exs:63: (test) 572 | 573 | 574 | 575 | 4) test renders page not found when id is nonexistent (TvRecipe.UserControllerTest) 576 | test/controllers/user_controller_test.exs:46 577 | expected error to be sent as 404 status, but response sent 302 without error 578 | stacktrace: 579 | (phoenix) lib/phoenix/test/conn_test.ex:570: Phoenix.ConnTest.assert_error_sent/2 580 | test/controllers/user_controller_test.exs:47: (test) 581 | 582 | . 583 | 584 | 5) test does not update chosen resource and renders errors when data is invalid (TvRecipe.UserControllerTest) 585 | test/controllers/user_controller_test.exs:68 586 | ** (RuntimeError) expected response with status 200, got: 302, with body: 587 | You are being redirected. 588 | stacktrace: 589 | (phoenix) lib/phoenix/test/conn_test.ex:362: Phoenix.ConnTest.response/2 590 | (phoenix) lib/phoenix/test/conn_test.ex:376: Phoenix.ConnTest.html_response/2 591 | test/controllers/user_controller_test.exs:71: (test) 592 | 593 | ...... 594 | 595 | Finished in 0.5 seconds 596 | 36 tests, 5 failures 597 | ``` 598 | 因为代码的改动,我们的测试又有失败的。让我们修正它们: 599 | 600 | ```elixir 601 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs 602 | index fd57531..a1b75c6 100644 603 | --- a/test/controllers/user_controller_test.exs 604 | +++ b/test/controllers/user_controller_test.exs 605 | @@ -3,6 +3,7 @@ defmodule TvRecipe.UserControllerTest do 606 | 607 | alias TvRecipe.{Repo, User} 608 | @valid_attrs %{email: "chenxsan@gmail.com", password: "some content", username: "chenxsan"} 609 | + @another_valid_attrs %{email: "chenxsan+1@gmail.com", password: "some content", username: "samchen"} 610 | @invalid_attrs %{} 611 | 612 | setup %{conn: conn} = context do 613 | @@ -36,37 +37,26 @@ defmodule TvRecipe.UserControllerTest do 614 | end 615 | 616 | @tag logged_in: true 617 | - test "shows chosen resource", %{conn: conn} do 618 | - user = Repo.insert! %User{} 619 | + test "shows chosen resource", %{conn: conn, user: user} do 620 | conn = get conn, user_path(conn, :show, user) 621 | assert html_response(conn, 200) =~ "Show user" 622 | end 623 | 624 | @tag logged_in: true 625 | - test "renders page not found when id is nonexistent", %{conn: conn} do 626 | - assert_error_sent 404, fn -> 627 | - get conn, user_path(conn, :show, -1) 628 | @tag logged_in: true 629 | - test "renders page not found when id is nonexistent", %{conn: conn} do 630 | - assert_error_sent 404, fn -> 631 | - get conn, user_path(conn, :show, -1) 632 | - end 633 | - end 634 | - 635 | - @tag logged_in: true 636 | - test "renders form for editing chosen resource", %{conn: conn} do 637 | - user = Repo.insert! %User{} 638 | + test "renders form for editing chosen resource", %{conn: conn, user: user} do 639 | conn = get conn, user_path(conn, :edit, user) 640 | assert html_response(conn, 200) =~ "Edit user" 641 | end 642 | 643 | @tag logged_in: true 644 | - test "updates chosen resource and redirects when data is valid", %{conn: conn} do 645 | - user = Repo.insert! %User{} 646 | - conn = put conn, user_path(conn, :update, user), user: %{@valid_attrs | username: "samchen", email: "chenxsan+1@gmail.com"} 647 | + test "updates chosen resource and redirects when data is valid", %{conn: conn, user: user} do 648 | + conn = put conn, user_path(conn, :update, user), user: @another_valid_attrs 649 | assert redirected_to(conn) == user_path(conn, :show, user) 650 | - assert Repo.get_by(User, @valid_attrs |> Map.delete(:password)) 651 | + assert Repo.get_by(User, @another_valid_attrs |> Map.delete(:password)) 652 | end 653 | 654 | @tag logged_in: true 655 | - test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do 656 | - user = Repo.insert! %User{} 657 | + test "does not update chosen resource and renders errors when data is invalid", %{conn: conn, user: user} do 658 | conn = put conn, user_path(conn, :update, user), user: @invalid_attrs 659 | assert html_response(conn, 200) =~ "Edit user" 660 | end 661 | ``` 662 | 运行测试: 663 | 664 | ```bash 665 | $ mix test 666 | ................................... 667 | 668 | Finished in 0.4 seconds 669 | 35 tests, 0 failures 670 | ``` 671 | 全部通过。 -------------------------------------------------------------------------------- /07-recipe/01-gen-html.md: -------------------------------------------------------------------------------- 1 | # 生成菜谱样板文件 2 | 3 | 前面的章节里,我们完成了后端系统最重要的部分:用户。接下来我们要进入菜谱模块的开发。 4 | 5 | 我们先来确认下,菜谱有哪些属性需要保存: 6 | 7 | 属性名|类型|备注|是否必填|默认值 8 | ---|---|---|---|--- 9 | name|string|菜谱名|必填| 10 | title|string|节目名|必填| 11 | season|integer|第几季|必填|1 12 | episode|integer|第几集|必填|1 13 | content|text|内容|必填| 14 | user_id|integer|关联用户 id|必填| 15 | 16 | 这里我们可以直接使用 `mix phoenix.gen.html` 命令来生成菜谱相关的所有文件: 17 | 18 | ```bash 19 | $ mix phoenix.gen.html Recipe recipes name title season:integer episode:integer content:text user_id:references:users 20 | * creating web/controllers/recipe_controller.ex 21 | * creating web/templates/recipe/edit.html.eex 22 | * creating web/templates/recipe/form.html.eex 23 | * creating web/templates/recipe/index.html.eex 24 | * creating web/templates/recipe/new.html.eex 25 | * creating web/templates/recipe/show.html.eex 26 | * creating web/views/recipe_view.ex 27 | * creating test/controllers/recipe_controller_test.exs 28 | * creating web/models/recipe.ex 29 | * creating test/models/recipe_test.exs 30 | * creating priv/repo/migrations/20170206013306_create_recipe.exs 31 | 32 | Add the resource to your browser scope in web/router.ex: 33 | 34 | resources "/recipes", RecipeController 35 | 36 | Remember to update your repository by running migrations: 37 | 38 | $ mix ecto.migrate 39 | ``` 40 | ![mix phoenix.gen.html Recipe](../img/07-generate-recipe.png) 41 | 42 | 我们先按照提示把 `resources "/recipes", RecipeController` 加入 `web/router.ex` 文件中: 43 | 44 | ```elixir 45 | diff --git a/web/router.ex b/web/router.ex 46 | index e0811dc..a6d7cd5 100644 47 | --- a/web/router.ex 48 | +++ b/web/router.ex 49 | @@ -20,6 +20,7 @@ defmodule TvRecipe.Router do 50 | get "/", PageController, :index 51 | resources "/users", UserController, except: [:index, :delete] 52 | resources "/sessions", SessionController, only: [:new, :create, :delete] 53 | + resources "/recipes", RecipeController 54 | end 55 | ``` 56 | 57 | 但请不要着急执行 `mix ecto.migrate`,我们有几个需要调整的地方: 58 | 59 | 1. 新建的 `priv/repo/migrations/20170206013306_create_recipe.exs` 文件中,有如下一句代码: 60 | 61 | ```elixir 62 | add :user_id, references(:users, on_delete: :nothing) 63 | ``` 64 | `on_delete` 决定 `recipe` 关联的 `user` 被删时,我们要如何处置 `recipe`。`:nothing` 表示不动 `recipe`,`:delete_all` 表示悉数删除,这里我们使用 `:delete_all`。 65 | 2. 新建的 `web/models/recipe.ex` 文件中,有一句代码: 66 | 67 | ```elixir 68 | belongs_to :user, TvRecipe.User 69 | ``` 70 | 因为 `Recipe` 与 `User` 的关系是双向的,所以我们需要在 `user.ex` 文件中增加一句: 71 | 72 | ```elixir 73 | has_many :recipes, TvRecipe.Recipe 74 | ``` 75 | 3. 我们需要在 `recipe.ex` 文件中给 `season` 与 `episode` 设置默认值: 76 | 77 | ```elixir 78 | field :season, :integer, default: 1 79 | field :episode, :integer, default: 1 80 | ``` 81 | 现在,我们可以执行 `mix ecto.migrate` 了: 82 | 83 | ```bash 84 | $ mix ecto.migrate 85 | 86 | 12:45:54.141 [info] == Running TvRecipe.Repo.Migrations.CreateRecipe.change/0 forward 87 | 88 | 12:45:54.141 [info] create table recipes 89 | 90 | 12:45:54.146 [info] create index recipes_user_id_index 91 | 92 | 12:45:54.149 [info] == Migrated in 0.0s 93 | ``` 94 | 我们运行下测试看看: 95 | 96 | ```bash 97 | $ mix test 98 | mix test 99 | Compiling 24 files (.ex) 100 | Generated tv_recipe app 101 | ............................................... 102 | 103 | Finished in 0.5 seconds 104 | 47 tests, 0 failures 105 | ``` 106 | 新生成的测试目前悉数通过。 107 | 108 | -------------------------------------------------------------------------------- /07-recipe/02-recipe-scheme.md: -------------------------------------------------------------------------------- 1 | # Recipe 属性开发 2 | 3 | 在开发用户时,我们曾经分章节完成各个属性。但这里不再细分。 4 | 5 | 我们来看下 `mix phoenix.gen.html` 命令生成的 `recipe_test.exs` 文件内容: 6 | 7 | ```elixir 8 | defmodule TvRecipe.RecipeTest do 9 | use TvRecipe.ModelCase 10 | 11 | alias TvRecipe.Recipe 12 | 13 | @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"} 14 | @invalid_attrs %{} 15 | 16 | test "changeset with valid attributes" do 17 | changeset = Recipe.changeset(%Recipe{}, @valid_attrs) 18 | assert changeset.valid? 19 | end 20 | 21 | test "changeset with invalid attributes" do 22 | changeset = Recipe.changeset(%Recipe{}, @invalid_attrs) 23 | refute changeset.valid? 24 | end 25 | end 26 | ``` 27 | 很显然,默认生成的 `@valid_attrs` 是无效的,因为少了一个 `user_id`。 28 | 29 | 但我们先处理其它属性,因为比较简单。 30 | 31 | 我们先增加测试: 32 | 33 | ```elixir 34 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs 35 | index a974aad..27f02ea 100644 36 | --- a/test/models/recipe_test.exs 37 | +++ b/test/models/recipe_test.exs 38 | @@ -15,4 +15,29 @@ defmodule TvRecipe.RecipeTest do 39 | changeset = Recipe.changeset(%Recipe{}, @invalid_attrs) 40 | refute changeset.valid? 41 | end 42 | + 43 | + test "name is required" do 44 | + attrs = %{@valid_attrs | name: ""} 45 | + assert {:name, "请填写"} in errors_on(%Recipe{}, attrs) 46 | + end 47 | + 48 | + test "title is required" do 49 | + attrs = %{@valid_attrs | title: ""} 50 | + assert {:title, "请填写"} in errors_on(%Recipe{}, attrs) 51 | + end 52 | + 53 | + test "season is required" do 54 | + attrs = %{@valid_attrs | season: nil} 55 | + assert {:season, "请填写"} in errors_on(%Recipe{}, attrs) 56 | + end 57 | + 58 | + test "episode is required" do 59 | + attrs = %{@valid_attrs | episode: nil} 60 | + assert {:episode, "请填写"} in errors_on(%Recipe{}, attrs) 61 | + end 62 | + 63 | + test "season should greater than 0" do 64 | + attrs = %{@valid_attrs | season: 0} 65 | + assert {:season, "请输入大于 0 的数字"} in errors_on(%Recipe{}, attrs) 66 | + end 67 | + 68 | + test "episode should greater than 0" do 69 | + attrs = %{@valid_attrs | episode: 0} 70 | + assert {:episode, "请输入大于 0 的数字"} in errors_on(%Recipe{}, attrs) 71 | + end 72 | + 73 | + test "content is required" do 74 | + attrs = %{@valid_attrs | content: ""} 75 | + assert {:content, "请填写"} in errors_on(%Recipe{}, attrs) 76 | + end 77 | end 78 | ``` 79 | 然后修改 `recipe.ex` 文件,自定义验证消息: 80 | 81 | ```elixir 82 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex 83 | index 946d45c..8d34ed2 100644 84 | --- a/web/models/recipe.ex 85 | +++ b/web/models/recipe.ex 86 | @@ -18,6 +18,6 @@ defmodule TvRecipe.Recipe do 87 | def changeset(struct, params \\ %{}) do 88 | struct 89 | |> cast(params, [:name, :title, :season, :episode, :content]) 90 | - |> validate_required([:name, :title, :season, :episode, :content]) 91 | + |> validate_required([:name, :title, :season, :episode, :content], message: "请填写") 92 | + |> validate_number(:season, greater_than: 0, message: "请输入大于 0 的数字") 93 | + |> validate_number(:episode, greater_than: 0, message: "请输入大于 0 的数字") 94 | end 95 | end 96 | ``` 97 | ## `user_id` 98 | 99 | `user_id` 有两条规则: 100 | 101 | 1. `user_id` 必填 102 | 2. `user_id` 对应着 `users` 表中用户的 id,该用户在表中必须存在 103 | 104 | ### 必填 105 | 106 | 我们先处理 `user_id` 必填的规则,补充一个测试,如下: 107 | 108 | ```elixir 109 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs 110 | index 27f02ea..3a9630b 100644 111 | --- a/test/models/recipe_test.exs 112 | +++ b/test/models/recipe_test.exs 113 | @@ -3,7 +3,7 @@ defmodule TvRecipe.RecipeTest do 114 | 115 | alias TvRecipe.Recipe 116 | 117 | - @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"} 118 | + @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content", user_id: 1} 119 | @invalid_attrs %{} 120 | 121 | test "changeset with valid attributes" do 122 | @@ -40,4 +40,9 @@ defmodule TvRecipe.RecipeTest do 123 | attrs = %{@valid_attrs | content: ""} 124 | assert {:content, "请填写"} in errors_on(%Recipe{}, attrs) 125 | end 126 | + 127 | + test "user_id is required" do 128 | + attrs = %{@valid_attrs | user_id: nil} 129 | + assert {:user_id, "请填写"} in errors_on(%Recipe{}, attrs) 130 | + end 131 | end 132 | ``` 133 | 然后修改 `recipe.ex` 文件: 134 | 135 | ```elixir 136 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex 137 | index 8d34ed2..0520582 100644 138 | --- a/web/models/recipe.ex 139 | +++ b/web/models/recipe.ex 140 | @@ -17,7 +17,7 @@ defmodule TvRecipe.Recipe do 141 | """ 142 | def changeset(struct, params \\ %{}) do 143 | struct 144 | - |> cast(params, [:name, :title, :season, :episode, :content]) 145 | - |> validate_required([:name, :title, :season, :episode, :content], message: "请填写") 146 | + |> cast(params, [:name, :title, :season, :episode, :content, :user_id]) 147 | + |> validate_required([:name, :title, :season, :episode, :content, :user_id], message: "请填写") 148 | end 149 | end 150 | ``` 151 | 运行新增的测试: 152 | 153 | ```bash 154 | $ mix test test/models/recipe_test.exs:54 155 | Including tags: [line: "54"] 156 | Excluding tags: [:test] 157 | 158 | . 159 | 160 | Finished in 0.1 seconds 161 | 10 tests, 0 failures, 9 skipped 162 | ``` 163 | 注意,我们只测试前面新增的测试,`:54` 表示执行该文件中第 54 行开始的 `test` 块。 164 | 165 | ### `user_id` 所指向的用户应存在 166 | 167 | 我们在 `recipe_test.exs` 文件中再增加一个测试,确保 `user_id` 所指的用户存在: 168 | 169 | ```elixir 170 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs 171 | index 3a9630b..2e1191c 100644 172 | --- a/test/models/recipe_test.exs 173 | +++ b/test/models/recipe_test.exs 174 | @@ -1,7 +1,7 @@ 175 | defmodule TvRecipe.RecipeTest do 176 | use TvRecipe.ModelCase 177 | 178 | - alias TvRecipe.Recipe 179 | + alias TvRecipe.{Repo, Recipe} 180 | 181 | @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content", user_id: 1} 182 | @invalid_attrs %{} 183 | @@ -45,4 +45,9 @@ defmodule TvRecipe.RecipeTest do 184 | attrs = %{@valid_attrs | user_id: nil} 185 | assert {:user_id, "请填写"} in errors_on(%Recipe{}, attrs) 186 | end 187 | + 188 | + test "user_id should exist in users table" do 189 | + {:error, changeset} = Repo.insert Recipe.changeset(%Recipe{}, @valid_attrs) 190 | + assert {:user_id, "用户不存在"} in errors_on(changeset) 191 | + end 192 | end 193 | ``` 194 | 运行新增的测试: 195 | 196 | ```bash 197 | $ mix test test/models/recipe_test.exs:59 198 | Compiling 13 files (.ex) 199 | Including tags: [line: "59"] 200 | Excluding tags: [:test] 201 | 202 | 203 | 204 | 1) test user_id should exist in users table (TvRecipe.RecipeTest) 205 | test/models/recipe_test.exs:59 206 | ** (Ecto.ConstraintError) constraint error when attempting to insert struct: 207 | 208 | * foreign_key: recipes_user_id_fkey 209 | 210 | If you would like to convert this constraint into an error, please 211 | call foreign_key_constraint/3 in your changeset and define the proper 212 | constraint name. The changeset has not defined any constraint. 213 | 214 | stacktrace: 215 | (ecto) lib/ecto/repo/schema.ex:493: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3 216 | (elixir) lib/enum.ex:1229: Enum."-map/2-lists^map/1-0-"/2 217 | (ecto) lib/ecto/repo/schema.ex:479: Ecto.Repo.Schema.constraints_to_errors/3 218 | (ecto) lib/ecto/repo/schema.ex:213: anonymous fn/13 in Ecto.Repo.Schema.do_insert/4 219 | (ecto) lib/ecto/repo/schema.ex:684: anonymous fn/3 in Ecto.Repo.Schema.wrap_in_transaction/6 220 | (ecto) lib/ecto/adapters/sql.ex:615: anonymous fn/3 in Ecto.Adapters.SQL.do_transaction/3 221 | (db_connection) lib/db_connection.ex:1274: DBConnection.transaction_run/4 222 | (db_connection) lib/db_connection.ex:1198: DBConnection.run_begin/3 223 | (db_connection) lib/db_connection.ex:789: DBConnection.transaction/3 224 | test/models/recipe_test.exs:60: (test) 225 | 226 | 227 | 228 | Finished in 0.2 seconds 229 | 11 tests, 1 failure, 10 skipped 230 | ``` 231 | 测试失败,但 Phoenix 给了 [foreign_key_constraint](https://hexdocs.pm/ecto/Ecto.Changeset.html#foreign_key_constraint/3) 的提示: 232 | 233 | ```elixir 234 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex 235 | index 0520582..a0b42fd 100644 236 | --- a/web/models/recipe.ex 237 | +++ b/web/models/recipe.ex 238 | @@ -19,5 +19,6 @@ defmodule TvRecipe.Recipe do 239 | struct 240 | |> cast(params, [:name, :title, :season, :episode, :content, :user_id]) 241 | |> validate_required([:name, :title, :season, :episode, :content, :user_id], message: "请填写") 242 | + |> foreign_key_constraint(:user_id, message: "用户不存在") 243 | end 244 | end 245 | ``` 246 | 再次运行测试: 247 | 248 | ```bash 249 | $ mix test test/models/recipe_test.exs:59 250 | Compiling 13 files (.ex) 251 | Including tags: [line: "59"] 252 | Excluding tags: [:test] 253 | 254 | . 255 | 256 | Finished in 0.1 seconds 257 | 11 tests, 0 failures, 10 skipped 258 | ``` 259 | 测试通过。 260 | 261 | 但我们运行所有测试的话: 262 | 263 | ```bash 264 | $ mix test 265 | .......................................... 266 | 267 | 1) test creates resource and redirects when data is valid (TvRecipe.RecipeControllerTest) 268 | test/controllers/recipe_controller_test.exs:18 269 | ** (RuntimeError) expected redirection with status 302, got: 200 270 | stacktrace: 271 | (phoenix) lib/phoenix/test/conn_test.ex:443: Phoenix.ConnTest.redirected_to/2 272 | test/controllers/recipe_controller_test.exs:20: (test) 273 | 274 | 275 | 276 | 2) test updates chosen resource and redirects when data is valid (TvRecipe.RecipeControllerTest) 277 | test/controllers/recipe_controller_test.exs:47 278 | ** (RuntimeError) expected redirection with status 302, got: 200 279 | stacktrace: 280 | (phoenix) lib/phoenix/test/conn_test.ex:443: Phoenix.ConnTest.redirected_to/2 281 | test/controllers/recipe_controller_test.exs:50: (test) 282 | 283 | .......... 284 | 285 | Finished in 0.6 seconds 286 | 56 tests, 2 failures 287 | ``` 288 | `recipe_controller_test.exs` 文件中出现两个错误 - 不过我们留给下一章处理。 -------------------------------------------------------------------------------- /07-recipe/03-recipe-controller.md: -------------------------------------------------------------------------------- 1 | # Recipe 控制器 2 | 3 | 上一章结尾,我们运行 `mix test` 检查出 `recipe_controller_test.exs` 文件中的两个错误。 4 | 5 | 很显然,它们是因为 `@valid_attrs` 中缺少 `user_id` 导致的。 6 | 7 | 怎么办,在 `@valid_attrs` 中随意添加个 `user_id` 可不能解决问题 - 用户必须存在。 8 | 9 | 一个粗暴的解决办法,是在每个测试中新建一个用户,然后把用户 id 传给 `@valid_attrs`,但那样又要重复一堆代码,我们可以把新建用户部分抽取到 `setup` 中: 10 | 11 | ```elixir 12 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs 13 | index 646ebf2..51fdeab 100644 14 | --- a/test/controllers/recipe_controller_test.exs 15 | +++ b/test/controllers/recipe_controller_test.exs 16 | @@ -1,10 +1,16 @@ 17 | defmodule TvRecipe.RecipeControllerTest do 18 | use TvRecipe.ConnCase 19 | 20 | - alias TvRecipe.Recipe 21 | + alias TvRecipe.{Repo, User, Recipe} 22 | @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"} 23 | @invalid_attrs %{} 24 | 25 | + setup do 26 | + user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)}) 27 | + attrs = Map.put(@valid_attrs, :user_id, user.id) 28 | + {:ok, [attrs: attrs]} 29 | + end 30 | + 31 | test "lists all entries on index", %{conn: conn} do 32 | conn = get conn, recipe_path(conn, :index) 33 | assert html_response(conn, 200) =~ "Listing recipes" 34 | @@ -15,10 +21,10 @@ defmodule TvRecipe.RecipeControllerTest do 35 | assert html_response(conn, 200) =~ "New recipe" 36 | end 37 | + user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)}) 38 | + attrs = Map.put(@valid_attrs, :user_id, user.id) 39 | + {:ok, [attrs: attrs]} 40 | + end 41 | + 42 | test "lists all entries on index", %{conn: conn} do 43 | conn = get conn, recipe_path(conn, :index) 44 | assert html_response(conn, 200) =~ "Listing recipes" 45 | @@ -15,10 +21,10 @@ defmodule TvRecipe.RecipeControllerTest do 46 | assert html_response(conn, 200) =~ "New recipe" 47 | end 48 | 49 | - test "creates resource and redirects when data is valid", %{conn: conn} do 50 | - conn = post conn, recipe_path(conn, :create), recipe: @valid_attrs 51 | + test "creates resource and redirects when data is valid", %{conn: conn, attrs: attrs} do 52 | + conn = post conn, recipe_path(conn, :create), recipe: attrs 53 | assert redirected_to(conn) == recipe_path(conn, :index) 54 | - assert Repo.get_by(Recipe, @valid_attrs) 55 | + assert Repo.get_by(Recipe, attrs) 56 | end 57 | 58 | test "does not create resource and renders errors when data is invalid", %{conn: conn} do 59 | @@ -44,11 +50,11 @@ defmodule TvRecipe.RecipeControllerTest do 60 | assert html_response(conn, 200) =~ "Edit recipe" 61 | end 62 | 63 | - test "updates chosen resource and redirects when data is valid", %{conn: conn} do 64 | + test "updates chosen resource and redirects when data is valid", %{conn: conn, attrs: attrs} do 65 | recipe = Repo.insert! %Recipe{} 66 | - conn = put conn, recipe_path(conn, :update, recipe), recipe: @valid_attrs 67 | + conn = put conn, recipe_path(conn, :update, recipe), recipe: attrs 68 | assert redirected_to(conn) == recipe_path(conn, :show, recipe) 69 | - assert Repo.get_by(Recipe, @valid_attrs) 70 | + assert Repo.get_by(Recipe, attrs) 71 | end 72 | ``` 73 | 在 `setup` 块中,我们新建了一个用户,并且重新组合出真正有效的 recipe 属性 `attrs`,然后返回。 74 | 75 | 现在运行测试: 76 | 77 | ```bash 78 | $ mix test 79 | ...................................................... 80 | 81 | Finished in 0.8 seconds 82 | 56 tests, 0 failures 83 | ``` 84 | 非常好,全部通过了。 85 | 86 | 接下来,我们处理动作的权限问题。 87 | 88 | ## Recipe 动作的权限 89 | 90 | 我们先确认 `RecipeController` 模块中各个动作的权限要求: 91 | 92 | 动作名|是否需要登录 93 | ---|--- 94 | index|需要 95 | new|需要 96 | create|需要 97 | show|需要 98 | edit|需要 99 | update|需要 100 | delete|需要 101 | 102 | 都要登录?难道未登录用户不能查看其它用户创建的菜谱?当然可以,但我们将新建路由来满足这些需求。这一节,我们开发的是 Recipe 相关的管理动作。 103 | 104 | 前面章节中我们已经尝试过使用 `tag` 来标注用户登录状态下的测试,现在根据上面罗列的需求来修改 `recipe_controller_test.exs` 文件中的测试: 105 | 106 | ```elixir 107 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs 108 | index 51fdeab..5632f8c 100644 109 | --- a/test/controllers/recipe_controller_test.exs 110 | +++ b/test/controllers/recipe_controller_test.exs 111 | @@ -5,51 +5,65 @@ defmodule TvRecipe.RecipeControllerTest do 112 | @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"} 113 | @invalid_attrs %{} 114 | 115 | - setup do 116 | - user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)}) 117 | - attrs = Map.put(@valid_attrs, :user_id, user.id) 118 | - {:ok, [attrs: attrs]} 119 | + setup %{conn: conn} = context do 120 | + user_attrs = %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)} 121 | + user = Repo.insert! User.changeset(%User{}, user_attrs) 122 | + attrs = Map.put(@valid_attrs, :user_id, user.id) 123 | + if context[:logged_in] == true do 124 | + conn = post conn, session_path(conn, :create), session: user_attrs 125 | + {:ok, [conn: conn, attrs: attrs]} 126 | + else 127 | + {:ok, [attrs: attrs]} 128 | + end 129 | end 130 | - 131 | + 132 | + @tag logged_in: true 133 | test "lists all entries on index", %{conn: conn} do 134 | conn = get conn, recipe_path(conn, :index) 135 | assert html_response(conn, 200) =~ "Listing recipes" 136 | end 137 | 138 | + @tag logged_in: true 139 | test "renders form for new resources", %{conn: conn} do 140 | conn = get conn, recipe_path(conn, :new) 141 | assert html_response(conn, 200) =~ "New recipe" 142 | end 143 | 144 | + @tag logged_in: true 145 | test "creates resource and redirects when data is valid", %{conn: conn, attrs: attrs} do 146 | conn = post conn, recipe_path(conn, :create), recipe: attrs 147 | assert redirected_to(conn) == recipe_path(conn, :index) 148 | assert Repo.get_by(Recipe, attrs) 149 | end 150 | 151 | + @tag logged_in: true 152 | test "does not create resource and renders errors when data is invalid", %{conn: conn} do 153 | conn = post conn, recipe_path(conn, :create), recipe: @invalid_attrs 154 | assert html_response(conn, 200) =~ "New recipe" 155 | end 156 | 157 | + @tag logged_in: true 158 | test "shows chosen resource", %{conn: conn} do 159 | recipe = Repo.insert! %Recipe{} 160 | conn = get conn, recipe_path(conn, :show, recipe) 161 | assert html_response(conn, 200) =~ "Show recipe" 162 | end 163 | 164 | + @tag logged_in: true 165 | + @tag logged_in: true 166 | test "renders page not found when id is nonexistent", %{conn: conn} do 167 | assert_error_sent 404, fn -> 168 | get conn, recipe_path(conn, :show, -1) 169 | end 170 | end 171 | 172 | + @tag logged_in: true 173 | test "renders form for editing chosen resource", %{conn: conn} do 174 | recipe = Repo.insert! %Recipe{} 175 | conn = get conn, recipe_path(conn, :edit, recipe) 176 | assert html_response(conn, 200) =~ "Edit recipe" 177 | end 178 | 179 | + @tag logged_in: true 180 | test "updates chosen resource and redirects when data is valid", %{conn: conn, attrs: attrs} do 181 | recipe = Repo.insert! %Recipe{} 182 | conn = put conn, recipe_path(conn, :update, recipe), recipe: attrs 183 | @@ -57,12 +71,14 @@ defmodule TvRecipe.RecipeControllerTest do 184 | assert Repo.get_by(Recipe, attrs) 185 | end 186 | 187 | + @tag logged_in: true 188 | test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do 189 | recipe = Repo.insert! %Recipe{} 190 | conn = put conn, recipe_path(conn, :update, recipe), recipe: @invalid_attrs 191 | assert html_response(conn, 200) =~ "Edit recipe" 192 | end 193 | 194 | + @tag logged_in: true 195 | test "deletes chosen resource", %{conn: conn} do 196 | recipe = Repo.insert! %Recipe{} 197 | conn = delete conn, recipe_path(conn, :delete, recipe) 198 | ``` 199 | 200 | 我们给所有测试代码都加上了 `@tag logged_in` 的标签。 201 | 202 | 接下来我们需要一个验证用户登录状态的 plug,不巧我们在 `user_controller.ex` 文件中已经定义了一个 `login_require` 的 plug,现在是其它地方也要用到它 - 再放在 `user_controller.ex` 中并不合适,我们将它移到 `auth.ex` 文件中: 203 | 204 | ```elixir 205 | diff --git a/web/controllers/auth.ex b/web/controllers/auth.ex 206 | index e298b68..3dd3e7f 100644 207 | --- a/web/controllers/auth.ex 208 | +++ b/web/controllers/auth.ex 209 | @@ -1,5 +1,7 @@ 210 | defmodule TvRecipe.Auth do 211 | import Plug.Conn 212 | + import Phoenix.Controller 213 | + alias TvRecipe.Router.Helpers 214 | 215 | @doc """ 216 | 初始化选项 217 | @@ -21,4 +23,37 @@ defmodule TvRecipe.Auth do 218 | |> configure_session(renew: true) 219 | end 220 | 221 | + @doc """ 222 | + 检查用户登录状态 223 | + 224 | + Returns `conn` 225 | + """ 226 | + def login_require(conn, _opts) do 227 | + if conn.assigns.current_user do 228 | + conn 229 | + else 230 | + conn 231 | + |> put_flash(:info, "请先登录") 232 | + |> redirect(to: Helpers.session_path(conn, :new)) 233 | + |> halt() 234 | + end 235 | + end 236 | + 237 | + @doc """ 238 | + 检查用户是否授权访问动作 239 | + 240 | + Returns `conn` 241 | + """ 242 | + def self_require(conn, _opts) do 243 | + %{"id" => id} = conn.params 244 | + if String.to_integer(id) == conn.assigns.current_user.id do 245 | + conn 246 | + else 247 | + conn 248 | + |> put_flash(:error, "禁止访问未授权页面") 249 | + |> redirect(to: Helpers.user_path(conn, :show, conn.assigns.current_user)) 250 | + |> halt() 251 | + end 252 | + end 253 | + 254 | end 255 | \ No newline at end of file 256 | diff --git a/web/controllers/user_controller.ex b/web/controllers/user_controller.ex 257 | index 520d986..0f023d3 100644 258 | --- a/web/controllers/user_controller.ex 259 | +++ b/web/controllers/user_controller.ex 260 | @@ -49,36 +49,4 @@ defmodule TvRecipe.UserController do 261 | end 262 | end 263 | 264 | - @doc """ 265 | - 检查用户登录状态 266 | - 267 | - Returns `conn` 268 | - """ 269 | - def login_require(conn, _opts) do 270 | - if conn.assigns.current_user do 271 | - conn 272 | - else 273 | - conn 274 | - |> put_flash(:info, "请先登录") 275 | - |> redirect(to: session_path(conn, :new)) 276 | - |> halt() 277 | - end 278 | - end 279 | - 280 | - @doc """ 281 | end 282 | 283 | - @doc """ 284 | - 检查用户登录状态 285 | - 286 | - Returns `conn` 287 | - """ 288 | - def login_require(conn, _opts) do 289 | - if conn.assigns.current_user do 290 | - conn 291 | - else 292 | - conn 293 | - |> put_flash(:info, "请先登录") 294 | - |> redirect(to: session_path(conn, :new)) 295 | - |> halt() 296 | - end 297 | - end 298 | - 299 | - @doc """ 300 | - 检查用户是否授权访问动作 301 | - 302 | - Returns `conn` 303 | - """ 304 | - def self_require(conn, _opts) do 305 | - %{"id" => id} = conn.params 306 | - if String.to_integer(id) == conn.assigns.current_user.id do 307 | - conn 308 | - else 309 | - conn 310 | - |> put_flash(:error, "禁止访问未授权页面") 311 | - |> redirect(to: user_path(conn, :show, conn.assigns.current_user)) 312 | - |> halt() 313 | - end 314 | - end 315 | end 316 | ``` 317 | 注意,我们并非只是简单的移动文本到 `auth.ex` 文件中。在 `auth.ex` 头部,我们还引入了两行代码,并调整了两个 plug: 318 | 319 | ```elixir 320 | import Phoenix.Controller 321 | alias TvRecipe.Router.Helpers 322 | ``` 323 | `import Phoenix.Controller` 导入 `put_flash` 等方法,而 `alias TvRecipe.Router.Helpers` 让我们在 `auth.ex` 中可以快速书写各种路径。 324 | 325 | 接着在 `web.ex` 文件中 `import` 它: 326 | 327 | ```elixir 328 | diff --git a/web/web.ex b/web/web.ex 329 | index 50fd62e..9990080 100644 330 | --- a/web/web.ex 331 | +++ b/web/web.ex 332 | @@ -36,6 +36,7 @@ defmodule TvRecipe.Web do 333 | 334 | import TvRecipe.Router.Helpers 335 | import TvRecipe.Gettext 336 | + import TvRecipe.Auth, only: [login_require: 2, self_require: 2] 337 | end 338 | end 339 | ``` 340 | 注意,目前我们只在 `controller` 中 `import`,后面可能会需要在 `router` 中 `import`。 341 | 342 | 最后,将 plug 应用到 `recipe_controller.ex` 文件中: 343 | 344 | ```elixir 345 | diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex 346 | index 96a0276..c74b492 100644 347 | --- a/web/controllers/recipe_controller.ex 348 | +++ b/web/controllers/recipe_controller.ex 349 | @@ -1,6 +1,6 @@ 350 | defmodule TvRecipe.RecipeController do 351 | use TvRecipe.Web, :controller 352 | - 353 | + plug :login_require 354 | alias TvRecipe.Recipe 355 | 356 | def index(conn, _params) do 357 | ``` 358 | 我们这次并没有使用 `when action in`,plug 将应用到该文件中所有的动作。 359 | 360 | 运行测试: 361 | 362 | ```bash 363 | $ mix test 364 | ...................................................... 365 | 366 | Finished in 0.7 seconds 367 | 56 tests, 0 failures 368 | ``` 369 | 一切顺利。 370 | 371 | 但因为我们现在所有测试都针对登录状态,我们还需要补充下未登录状态的测试: 372 | 373 | ```elixir 374 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs 375 | index 5632f8c..faf67ca 100644 376 | --- a/test/controllers/recipe_controller_test.exs 377 | +++ b/test/controllers/recipe_controller_test.exs 378 | @@ -16,7 +16,7 @@ defmodule TvRecipe.RecipeControllerTest do 379 | {:ok, [attrs: attrs]} 380 | end 381 | end 382 | - 383 | + 384 | @tag logged_in: true 385 | test "lists all entries on index", %{conn: conn} do 386 | conn = get conn, recipe_path(conn, :index) 387 | @@ -85,4 +85,20 @@ defmodule TvRecipe.RecipeControllerTest do 388 | assert redirected_to(conn) == recipe_path(conn, :index) 389 | refute Repo.get(Recipe, recipe.id) 390 | end 391 | + 392 | + test "guest access user action redirected to login page", %{conn: conn} do 393 | + recipe = Repo.insert! %Recipe{} 394 | + Enum.each([ 395 | + get(conn, recipe_path(conn, :index)), 396 | + get(conn, recipe_path(conn, :new)), 397 | + get(conn, recipe_path(conn, :create), recipe: %{}), 398 | + get(conn, recipe_path(conn, :show, recipe)), 399 | + get(conn, recipe_path(conn, :edit, recipe)), 400 | + put(conn, recipe_path(conn, :update, recipe), recipe: %{}), 401 | + put(conn, recipe_path(conn, :delete, recipe)), 402 | + ], fn conn -> 403 | + assert redirected_to(conn) == session_path(conn, :new) 404 | + assert conn.halted 405 | + end) 406 | + end 407 | end 408 | ``` 409 | 410 | ## `user_id` 与 `:current_user` 411 | 412 | 在前面的测试里,我们先创建一个新用户,然后登录新用户,新建 recipe,并将新用户的 id 与新建的 recipe 关联起来。 413 | 414 | 考虑另一种情况: 415 | 416 | 1. 新建用户 A 417 | 2. 新建用户 B 418 | 3. 登录用户 B 419 | 4. 关联用户 A 的 id 与 Recipe 420 | 421 | 这一种情况,我们的测试还没有覆盖到。 422 | 423 | 我们来新增一个测试,验证一下: 424 | 425 | ```elixir 426 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs 427 | index faf67ca..d8157a2 100644 428 | --- a/test/controllers/recipe_controller_test.exs 429 | +++ b/test/controllers/recipe_controller_test.exs 430 | @@ -101,4 +101,17 @@ defmodule TvRecipe.RecipeControllerTest do 431 | assert conn.halted 432 | end) 433 | end 434 | + 435 | + @tag logged_in: true 436 | + test "creates resource and redirects when data is valid but with other user_id", %{conn: conn, attrs: attrs} do 437 | + # 新建一个用户 438 | + user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan+1@gmail.com", username: "samchen", password: String.duplicate("1", 6)}) 439 | + # 将新用户的 id 更新入 attrs,尝试替 samchen 创建一个菜谱 440 | + new_attrs = %{attrs | user_id: user.id} 441 | + post conn, recipe_path(conn, :create), recipe: new_attrs 442 | + # 用户 chenxsan 只能创建自己的菜谱,无法替 samchen 创建菜谱 443 | + assert Repo.get_by(Recipe, attrs) 444 | + # samchen 不应该有菜谱 445 | + refute Repo.get_by(Recipe, new_attrs) 446 | + end 447 | end 448 | ``` 449 | 运行测试: 450 | 451 | ```bash 452 | $ mix test 453 | .......................... 454 | 455 | 1) test creates resource and redirects when data is valid but with other user_id (TvRecipe.RecipeControllerTest) 456 | test/controllers/recipe_controller_test.exs:106 457 | Expected truthy, got nil 458 | code: Repo.get_by(Recipe, attrs) 459 | stacktrace: 460 | test/controllers/recipe_controller_test.exs:113: (test) 461 | 462 | ............................. 463 | 464 | Finished in 0.7 seconds 465 | 58 tests, 1 failure 466 | ``` 467 | 测试失败:登录状态下的用户 A 给用户 B 创建了一个菜谱。 468 | 469 | 那么要如何修改我们的控制器代码? 470 | 471 | 我们可以像测试中一样,把登录用户的 id 传递进去,比如: 472 | 473 | ```elixir 474 | def create(conn, %{"recipe" => recipe_params}) do 475 | changeset = Recipe.changeset(%Recipe{}, Map.put(recipe_params, "user_id", conn.assigns.current_user.id)) 476 | ``` 477 | 可是,我们为什么要提供给用户传递 `user_id` 的机会呢? 478 | 479 | 让我们从 `recipe.ex` 文件中把 `user_id` 相关的代码去掉: 480 | 481 | ```elixir 482 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex 483 | index a0b42fd..8d34ed2 100644 484 | --- a/web/models/recipe.ex 485 | +++ b/web/models/recipe.ex 486 | @@ -17,8 +17,7 @@ defmodule TvRecipe.Recipe do 487 | """ 488 | def changeset(struct, params \\ %{}) do 489 | struct 490 | - |> cast(params, [:name, :title, :season, :episode, :content, :user_id]) 491 | - |> validate_required([:name, :title, :season, :episode, :content, :user_id], message: "请填写") 492 | - |> foreign_key_constraint(:user_id, message: "用户不存在") 493 | + |> cast(params, [:name, :title, :season, :episode, :content]) 494 | + |> validate_required([:name, :title, :season, :episode, :content], message: "请填写") 495 | end 496 | end 497 | ``` 498 | `recipe_test.exs` 中 `user_id` 相关的代码也要去掉: 499 | 500 | ```elixir 501 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs 502 | index 2e1191c..4e59fb9 100644 503 | --- a/test/models/recipe_test.exs 504 | +++ b/test/models/recipe_test.exs 505 | @@ -3,7 +3,7 @@ defmodule TvRecipe.RecipeTest do 506 | 507 | alias TvRecipe.{Recipe} 508 | 509 | - @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content", user_id: 1} 510 | + @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"} 511 | @invalid_attrs %{} 512 | 513 | test "changeset with valid attributes" do 514 | @@ -40,14 +40,5 @@ defmodule TvRecipe.RecipeTest do 515 | attrs = %{@valid_attrs | content: ""} 516 | assert {:content, "请填写"} in errors_on(%Recipe{}, attrs) 517 | end 518 | - 519 | - test "user_id is required" do 520 | - attrs = %{@valid_attrs | user_id: nil} 521 | - assert {:user_id, "请填写"} in errors_on(%Recipe{}, attrs) 522 | - end 523 | 524 | - test "user_id should exist in users table" do 525 | - {:error, changeset} = Repo.insert Recipe.changeset(%Recipe{}, @valid_attrs) 526 | - assert {:user_id, "用户不存在"} in errors_on(changeset) 527 | - end 528 | end 529 | ``` 530 | 不要忘了还有 `recipe_controller_test.exs` 文件中的代码: 531 | 532 | ```elixir 533 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs 534 | index d8157a2..d953315 100644 535 | --- a/test/controllers/recipe_controller_test.exs 536 | +++ b/test/controllers/recipe_controller_test.exs 537 | @@ -7,13 +7,12 @@ defmodule TvRecipe.RecipeControllerTest do 538 | 539 | setup %{conn: conn} = context do 540 | user_attrs = %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)} 541 | - user = Repo.insert! User.changeset(%User{}, user_attrs) 542 | - attrs = Map.put(@valid_attrs, :user_id, user.id) 543 | + Repo.insert! User.changeset(%User{}, user_attrs) 544 | if context[:logged_in] == true do 545 | conn = post conn, session_path(conn, :create), session: user_attrs 546 | - {:ok, [conn: conn, attrs: attrs]} 547 | + {:ok, [conn: conn]} 548 | else 549 | - {:ok, [attrs: attrs]} 550 | + :ok 551 | end 552 | end 553 | 554 | @@ -30,10 +29,10 @@ defmodule TvRecipe.RecipeControllerTest do 555 | end 556 | 557 | @tag logged_in: true 558 | - test "creates resource and redirects when data is valid", %{conn: conn, attrs: attrs} do 559 | - conn = post conn, recipe_path(conn, :create), recipe: attrs 560 | + test "creates resource and redirects when data is valid", %{conn: conn} do 561 | + conn = post conn, recipe_path(conn, :create), recipe: @valid_attrs 562 | assert redirected_to(conn) == recipe_path(conn, :index) 563 | - assert Repo.get_by(Recipe, attrs) 564 | + assert Repo.get_by(Recipe, @valid_attrs) 565 | end 566 | 567 | @tag logged_in: true 568 | @@ -64,11 +63,11 @@ defmodule TvRecipe.RecipeControllerTest do 569 | @@ -64,11 +63,11 @@ defmodule TvRecipe.RecipeControllerTest do 570 | end 571 | 572 | @tag logged_in: true 573 | - test "updates chosen resource and redirects when data is valid", %{conn: conn, attrs: attrs} do 574 | + test "updates chosen resource and redirects when data is valid", %{conn: conn} do 575 | recipe = Repo.insert! %Recipe{} 576 | - conn = put conn, recipe_path(conn, :update, recipe), recipe: attrs 577 | + conn = put conn, recipe_path(conn, :update, recipe), recipe: @valid_attrs 578 | assert redirected_to(conn) == recipe_path(conn, :show, recipe) 579 | - assert Repo.get_by(Recipe, attrs) 580 | + assert Repo.get_by(Recipe, @valid_attrs) 581 | end 582 | 583 | @tag logged_in: true 584 | @@ -102,16 +101,4 @@ defmodule TvRecipe.RecipeControllerTest do 585 | end) 586 | end 587 | 588 | - @tag logged_in: true 589 | - test "creates resource and redirects when data is valid but with other user_id", %{conn: conn, attrs: attrs} do 590 | - # 新建一个用户 591 | - user = Repo.insert! User.changeset(%User{}, %{email: "chenxsan+1@gmail.com", username: "samchen", password: String.duplicate("1", 6)}) 592 | - # 将新用户的 id 更新入 attrs,尝试替 samchen 创建一个菜谱 593 | - new_attrs = %{attrs | user_id: user.id} 594 | - post conn, recipe_path(conn, :create), recipe: new_attrs 595 | - # 用户 chenxsan 只能创建自己的菜谱,无法替 samchen 创建菜谱 596 | - assert Repo.get_by(Recipe, attrs) 597 | - # samchen 不应该有菜谱 598 | - refute Repo.get_by(Recipe, new_attrs) 599 | - end 600 | end 601 | ``` 602 | 是的,绕了一圈,我们把前面新增的那个测试给删除了,因为 `Recipe.changeset` 已经不再接收 `user_id`,那个测试已经失去意义。 603 | 604 | 那么,我们要怎样将当前登录的用户 id 置入 recipe 中? 605 | 606 | Ecto 提供了 [build_assoc](https://hexdocs.pm/ecto/Ecto.html#build_assoc/3) 方法,用于处理这类“关联”,我们来改造下 `recipe_controller.ex` 文件: 607 | 608 | ```elixir 609 | diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex 610 | index c74b492..967b7bc 100644 611 | --- a/web/controllers/recipe_controller.ex 612 | +++ b/web/controllers/recipe_controller.ex 613 | @@ -9,12 +9,18 @@ defmodule TvRecipe.RecipeController do 614 | end 615 | 616 | def new(conn, _params) do 617 | - changeset = Recipe.changeset(%Recipe{}) 618 | + changeset = 619 | + conn.assigns.current_user 620 | + |> build_assoc(:recipes) 621 | + |> Recipe.changeset() 622 | render(conn, "new.html", changeset: changeset) 623 | end 624 | 625 | def create(conn, %{"recipe" => recipe_params}) do 626 | - changeset = Recipe.changeset(%Recipe{}, recipe_params) 627 | + changeset = 628 | + conn.assigns.current_user 629 | + |> build_assoc(:recipes) 630 | + |> Recipe.changeset(recipe_params) 631 | 632 | case Repo.insert(changeset) do 633 | {:ok, _recipe} -> 634 | ``` 635 | 你可能会好奇此时的 `changeset`,我们可以使用 `IO.inspect(changeset)` 在终端窗口中打印出来,它大致是这个样子: 636 | 637 | ```bash 638 | #Ecto.Changeset, valid?: true> 641 | ``` 642 | 可是其中并没有看到 `user_id` - 那么 `build_assoc` 构建的数据存放在哪?在 `changeset.data` 下,大致是这样: 643 | 644 | ```bash 645 | %TvRecipe.Recipe{__meta__: #Ecto.Schema.Metadata<:built, "recipes">, 646 | content: nil, episode: nil, id: nil, inserted_at: nil, name: nil, season: nil, 647 | title: nil, updated_at: nil, 648 | user: #Ecto.Association.NotLoaded, user_id: 1} 649 | ``` 650 | 咦,上面看起来有点像魔法。 651 | 652 | 但我们仔细阅读 `Repo.insert` 的[说明](https://hexdocs.pm/ecto/Ecto.Repo.html#c:insert/2),可以看到如下一段: 653 | 654 | > In case a changeset is given, the changes in the changeset are merged with the struct fields, and all of them are sent to the database. 655 | 656 | 其中有个关键词 **merged**,是的,存放在 `changeset.data` 下的数据,会被合并进去,这解释了我们 `user_id` 不在 `changeset.changes` 下,却最终在数据库中出现的魔法。 657 | 658 | 最后,我们还要调整些代码,来检验新建的 recipe 中是否包含了当前登录用户的 id: 659 | 660 | ```elixir 661 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs 662 | index d953315..b901b61 100644 663 | --- a/test/controllers/recipe_controller_test.exs 664 | +++ b/test/controllers/recipe_controller_test.exs 665 | @@ -7,10 +7,10 @@ defmodule TvRecipe.RecipeControllerTest do 666 | 667 | setup %{conn: conn} = context do 668 | user_attrs = %{email: "chenxsan@gmail.com", username: "chenxsan", password: String.duplicate("1", 6)} 669 | - Repo.insert! User.changeset(%User{}, user_attrs) 670 | + user = Repo.insert! User.changeset(%User{}, user_attrs) 671 | if context[:logged_in] == true do 672 | conn = post conn, session_path(conn, :create), session: user_attrs 673 | - {:ok, [conn: conn]} 674 | + {:ok, [conn: conn, user: user]} 675 | else 676 | :ok 677 | end 678 | @@ -29,10 +29,10 @@ defmodule TvRecipe.RecipeControllerTest do 679 | end 680 | 681 | @tag logged_in: true 682 | - test "creates resource and redirects when data is valid", %{conn: conn} do 683 | + test "creates resource and redirects when data is valid", %{conn: conn, user: user} do 684 | conn = post conn, recipe_path(conn, :create), recipe: @valid_attrs 685 | assert redirected_to(conn) == recipe_path(conn, :index) 686 | - assert Repo.get_by(Recipe, @valid_attrs) 687 | + assert Repo.get_by(Recipe, Map.put(@valid_attrs, :user_id, user.id)) 688 | end 689 | 690 | @tag logged_in: true 691 | ``` 692 | 运行测试: 693 | 694 | ```bash 695 | mix test 696 | ..................................................... 697 | 698 | Finished in 0.7 seconds 699 | 55 tests, 0 failures 700 | ``` 701 | 全部通过。 702 | 703 | ## 我的 recipes 704 | 705 | 我们还有一个情况未处理,用户 A 登录后现在可以查看、编辑、更新用户 B 的菜谱的,我们要禁止这些动作。 706 | 707 | 写个测试验证一下: 708 | 709 | ```elixir 710 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs 711 | index b901b61..cdbc420 100644 712 | --- a/test/controllers/recipe_controller_test.exs 713 | +++ b/test/controllers/recipe_controller_test.exs 714 | @@ -101,4 +101,19 @@ defmodule TvRecipe.RecipeControllerTest do 715 | end) 716 | end 717 | 718 | + @tag logged_in: true 719 | + test "user should not allowed to show recipe of other people", %{conn: conn, user: user} do 720 | + # 当前登录用户创建了一个菜谱 721 | + conn = post conn, recipe_path(conn, :create), recipe: @valid_attrs 722 | + recipe = Repo.get_by(Recipe, Map.put(@valid_attrs, :user_id, user.id)) 723 | + # 新建一个用户 724 | + new_user_attrs = %{email: "chenxsan+1@gmail.com", "username": "samchen", password: String.duplicate("1", 6)} 725 | + Repo.insert! User.changeset(%User{}, new_user_attrs) 726 | + # 登录新建的用户 727 | + conn = post conn, session_path(conn, :create), session: new_user_attrs 728 | + # 读取前头的 recipe 失败,因为它不属于新用户所有 729 | + assert_error_sent 404, fn -> 730 | + get conn, recipe_path(conn, :show, recipe) 731 | + end 732 | + end 733 | end 734 | ``` 735 | 运行测试: 736 | 737 | ```bash 738 | mix test 739 | Compiling 1 file (.ex) 740 | ................................ 741 | 742 | 1) test user should not allowed to show recipe of other people (TvRecipe.RecipeControllerTest) 743 | test/controllers/recipe_controller_test.exs:105 744 | expected error to be sent as 404 status, but response sent 200 without error 745 | stacktrace: 746 | (phoenix) lib/phoenix/test/conn_test.ex:570: Phoenix.ConnTest.assert_error_sent/2 747 | test/controllers/recipe_controller_test.exs:115: (test) 748 | 749 | ..................... 750 | 751 | Finished in 0.8 seconds 752 | 56 tests, 1 failure 753 | ``` 754 | Oops,报错了,我们期望响应是 404,却得到 200。 755 | 756 | 那么该如何取得当前登录用户自有的菜谱? 757 | 758 | 既然我们前面已经定义过用户与菜谱的关联关系,那么一切应该很容易才是。 759 | 760 | 是的,Ecto 提供了一个 [`assoc` 方法](https://hexdocs.pm/ecto/Ecto.html#assoc/2),它能帮我们取得用户关联的所有菜谱。 761 | 762 | 我们调整下 `recipe_controller.ex` 文件: 763 | 764 | ```elixir 765 | diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex 766 | index 967b7bc..22554ea 100644 767 | --- a/web/controllers/recipe_controller.ex 768 | +++ b/web/controllers/recipe_controller.ex 769 | @@ -33,7 +33,7 @@ defmodule TvRecipe.RecipeController do 770 | end 771 | 772 | def show(conn, %{"id" => id}) do 773 | - recipe = Repo.get!(Recipe, id) 774 | + recipe = Repo.get!(assoc(conn.assigns.current_user, :recipes), id) 775 | render(conn, "show.html", recipe: recipe) 776 | end 777 | ``` 778 | 再运行测试: 779 | 780 | ```bash 781 | $ mix test 782 | .......................... 783 | 784 | 1) test shows chosen resource (TvRecipe.RecipeControllerTest) 785 | test/controllers/recipe_controller_test.exs:45 786 | ** (Ecto.NoResultsError) expected at least one result but got none in query: 787 | 788 | from r in TvRecipe.Recipe, 789 | where: r.user_id == ^2469, 790 | where: r.id == ^"645" 791 | 792 | stacktrace: 793 | (ecto) lib/ecto/repo/queryable.ex:78: Ecto.Repo.Queryable.one!/4 794 | (tv_recipe) web/controllers/recipe_controller.ex:36: TvRecipe.RecipeController.show/2 795 | (tv_recipe) web/controllers/recipe_controller.ex:1: TvRecipe.RecipeController.action/2 796 | (tv_recipe) web/controllers/recipe_controller.ex:1: TvRecipe.RecipeController.phoenix_controller_pipeline/2 797 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.instrument/4 798 | (tv_recipe) lib/phoenix/router.ex:261: TvRecipe.Router.dispatch/2 799 | (tv_recipe) web/router.ex:1: TvRecipe.Router.do_call/2 800 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.phoenix_pipeline/1 801 | (tv_recipe) lib/tv_recipe/endpoint.ex:1: TvRecipe.Endpoint.call/2 802 | (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5 803 | test/controllers/recipe_controller_test.exs:47: (test) 804 | 805 | ........................... 806 | 807 | Finished in 0.8 seconds 808 | 56 tests, 1 failure 809 | ``` 810 | 我们修复了前面一个错误,但因为我们的修复代码导致了另一个新错误。 811 | 812 | 检查测试代码,可以发现,旧的测试代码已经不适用了,因为它们新建的 recipe 不包含 `user_id`。我们调整一下: 813 | 814 | ```elixir 815 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs 816 | index cdbc420..d93bbd1 100644 817 | --- a/test/controllers/recipe_controller_test.exs 818 | +++ b/test/controllers/recipe_controller_test.exs 819 | @@ -42,8 +42,8 @@ defmodule TvRecipe.RecipeControllerTest do 820 | end 821 | 822 | @tag logged_in: true 823 | - test "shows chosen resource", %{conn: conn} do 824 | - recipe = Repo.insert! %Recipe{} 825 | + test "shows chosen resource", %{conn: conn, user: user} do 826 | + recipe = Repo.insert! %Recipe{user_id: user.id} 827 | conn = get conn, recipe_path(conn, :show, recipe) 828 | assert html_response(conn, 200) =~ "Show recipe" 829 | end 830 | ``` 831 | 再运行测试: 832 | 833 | ```elixir 834 | $ mix test 835 | ...................................................... 836 | 837 | Finished in 0.8 seconds 838 | 56 tests, 0 failures 839 | ``` 840 | 通过了。 841 | 842 | 但上面我们只修改了 `show` 这个动作,其它几个动作同样需要修改: 843 | 844 | ```elixir 845 | diff --git a/web/controllers/recipe_controller.ex b/web/controllers/recipe_controller.ex 846 | index 22554ea..f317b59 100644 847 | --- a/web/controllers/recipe_controller.ex 848 | +++ b/web/controllers/recipe_controller.ex 849 | @@ -4,7 +4,7 @@ defmodule TvRecipe.RecipeController do 850 | alias TvRecipe.Recipe 851 | 852 | def index(conn, _params) do 853 | - recipes = Repo.all(Recipe) 854 | + recipes = Repo.all(assoc(conn.assigns.current_user, :recipes)) 855 | render(conn, "index.html", recipes: recipes) 856 | end 857 | 858 | @@ -38,13 +38,13 @@ defmodule TvRecipe.RecipeController do 859 | end 860 | 861 | def edit(conn, %{"id" => id}) do 862 | - recipe = Repo.get!(Recipe, id) 863 | + recipe = Repo.get!(assoc(conn.assigns.current_user, :recipes), id) 864 | changeset = Recipe.changeset(recipe) 865 | render(conn, "edit.html", recipe: recipe, changeset: changeset) 866 | end 867 | 868 | def update(conn, %{"id" => id, "recipe" => recipe_params}) do 869 | - recipe = Repo.get!(Recipe, id) 870 | + recipe = Repo.get!(assoc(conn.assigns.current_user, :recipes), id) 871 | changeset = Recipe.changeset(recipe, recipe_params) 872 | 873 | case Repo.update(changeset) do 874 | @@ -58,7 +58,7 @@ defmodule TvRecipe.RecipeController do 875 | end 876 | 877 | def delete(conn, %{"id" => id}) do 878 | - recipe = Repo.get!(Recipe, id) 879 | + recipe = Repo.get!(assoc(conn.assigns.current_user, :recipes), id) 880 | 881 | # Here we use delete! (with a bang) because we expect 882 | # it to always work (and if it does not, it will raise). 883 | ``` 884 | 别忘了修复测试: 885 | 886 | ```elixir 887 | diff --git a/test/controllers/recipe_controller_test.exs b/test/controllers/recipe_controller_test.exs 888 | index d93bbd1..190ede9 100644 889 | --- a/test/controllers/recipe_controller_test.exs 890 | +++ b/test/controllers/recipe_controller_test.exs 891 | @@ -56,30 +56,30 @@ defmodule TvRecipe.RecipeControllerTest do 892 | end 893 | 894 | @tag logged_in: true 895 | - test "renders form for editing chosen resource", %{conn: conn} do 896 | - recipe = Repo.insert! %Recipe{} 897 | end 898 | 899 | @tag logged_in: true 900 | - test "renders form for editing chosen resource", %{conn: conn} do 901 | - recipe = Repo.insert! %Recipe{} 902 | + test "renders form for editing chosen resource", %{conn: conn, user: user} do 903 | + recipe = Repo.insert! %Recipe{user_id: user.id} 904 | conn = get conn, recipe_path(conn, :edit, recipe) 905 | assert html_response(conn, 200) =~ "Edit recipe" 906 | end 907 | 908 | @tag logged_in: true 909 | - test "updates chosen resource and redirects when data is valid", %{conn: conn} do 910 | - recipe = Repo.insert! %Recipe{} 911 | + test "updates chosen resource and redirects when data is valid", %{conn: conn, user: user} do 912 | + recipe = Repo.insert! %Recipe{user_id: user.id} 913 | conn = put conn, recipe_path(conn, :update, recipe), recipe: @valid_attrs 914 | assert redirected_to(conn) == recipe_path(conn, :show, recipe) 915 | assert Repo.get_by(Recipe, @valid_attrs) 916 | end 917 | 918 | @tag logged_in: true 919 | - test "does not update chosen resource and renders errors when data is invalid", %{conn: conn} do 920 | - recipe = Repo.insert! %Recipe{} 921 | + test "does not update chosen resource and renders errors when data is invalid", %{conn: conn, user: user} do 922 | + recipe = Repo.insert! %Recipe{user_id: user.id} 923 | conn = put conn, recipe_path(conn, :update, recipe), recipe: @invalid_attrs 924 | assert html_response(conn, 200) =~ "Edit recipe" 925 | end 926 | 927 | @tag logged_in: true 928 | - test "deletes chosen resource", %{conn: conn} do 929 | - recipe = Repo.insert! %Recipe{} 930 | + test "deletes chosen resource", %{conn: conn, user: user} do 931 | + recipe = Repo.insert! %Recipe{user_id: user.id} 932 | conn = delete conn, recipe_path(conn, :delete, recipe) 933 | assert redirected_to(conn) == recipe_path(conn, :index) 934 | refute Repo.get(Recipe, recipe.id) 935 | ``` 936 | 937 | ## 数据的完整性 938 | 939 | 前面我们在处理 `user_id` 时,顺手删除了 `foreign_key_constraint`,那么,我们要如何处理这样一种情况:用户提交创建菜谱的请求,但用户突然被管理员删除。这时我们的数据库里就会出现无主的菜谱。我们希望避免这种情况。 940 | 941 | 我们在 `recipe_test.exs` 文件中重新增加一个测试: 942 | 943 | ```elixir 944 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs 945 | index 4dbc961..2e2b518 100644 946 | --- a/test/models/recipe_test.exs 947 | +++ b/test/models/recipe_test.exs 948 | @@ -1,7 +1,7 @@ 949 | defmodule TvRecipe.RecipeTest do 950 | use TvRecipe.ModelCase 951 | 952 | - alias TvRecipe.{Recipe} 953 | + alias TvRecipe.{Repo, User, Recipe} 954 | 955 | @valid_attrs %{content: "some content", episode: 42, name: "some content", season: 42, title: "some content"} 956 | @invalid_attrs %{} 957 | @@ -41,4 +41,13 @@ defmodule TvRecipe.RecipeTest do 958 | assert {:content, "请填写"} in errors_on(%Recipe{}, attrs) 959 | end 960 | 961 | + test "user must exist" do 962 | + changeset = 963 | + %User{id: -1} 964 | + |> Ecto.build_assoc(:recipes) 965 | + |> Recipe.changeset(@valid_attrs) 966 | + {:error, changeset} = Repo.insert changeset 967 | + assert {:user_id, "does not exist"} in errors_on(changeset) 968 | + end 969 | + 970 | end 971 | ``` 972 | 运行测试: 973 | 974 | ```elixir 975 | mix test 976 | Compiling 13 files (.ex) 977 | ............................................. 978 | 979 | 1) test user must exist (TvRecipe.RecipeTest) 980 | test/models/recipe_test.exs:44 981 | ** (Ecto.ConstraintError) constraint error when attempting to insert struct: 982 | 983 | * foreign_key: recipes_user_id_fkey 984 | 985 | If you would like to convert this constraint into an error, please 986 | call foreign_key_constraint/3 in your changeset and define the proper 987 | constraint name. The changeset has not defined any constraint. 988 | 989 | stacktrace: 990 | (ecto) lib/ecto/repo/schema.ex:493: anonymous fn/4 in Ecto.Repo.Schema.constraints_to_errors/3 991 | (elixir) lib/enum.ex:1229: Enum."-map/2-lists^map/1-0-"/2 992 | (ecto) lib/ecto/repo/schema.ex:479: Ecto.Repo.Schema.constraints_to_errors/3 993 | (ecto) lib/ecto/repo/schema.ex:213: anonymous fn/13 in Ecto.Repo.Schema.do_insert/4 994 | (ecto) lib/ecto/repo/schema.ex:684: anonymous fn/3 in Ecto.Repo.Schema.wrap_in_transaction/6 995 | (ecto) lib/ecto/adapters/sql.ex:615: anonymous fn/3 in Ecto.Adapters.SQL.do_transaction/3 996 | (db_connection) lib/db_connection.ex:1274: DBConnection.transaction_run/4 997 | (db_connection) lib/db_connection.ex:1198: DBConnection.run_begin/3 998 | (db_connection) lib/db_connection.ex:789: DBConnection.transaction/3 999 | test/models/recipe_test.exs:49: (test) 1000 | 1001 | ......... 1002 | 1003 | Finished in 0.7 seconds 1004 | 57 tests, 1 failure 1005 | ``` 1006 | 很好,`foreign_key_constraint` 的提示又出来了。修改 `recipe.ex` 文件: 1007 | 1008 | ```elixr 1009 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex 1010 | index 8d34ed2..fcc97ad 100644 1011 | --- a/web/models/recipe.ex 1012 | +++ b/web/models/recipe.ex 1013 | @@ -19,5 +19,6 @@ defmodule TvRecipe.Recipe do 1014 | struct 1015 | |> cast(params, [:name, :title, :season, :episode, :content]) 1016 | |> validate_required([:name, :title, :season, :episode, :content], message: "请填写") 1017 | + |> foreign_key_constraint(:user_id) 1018 | end 1019 | end 1020 | ``` 1021 | 再次运行测试: 1022 | 1023 | ```bash 1024 | mix test 1025 | Compiling 13 files (.ex) 1026 | ....................................................... 1027 | 1028 | Finished in 0.7 seconds 1029 | 57 tests, 0 failures 1030 | ``` 1031 | 1032 | 悉数通过。至此,我们完成了 `RecipeController` 的测试。下一章,我们将接触 View 的测试。 -------------------------------------------------------------------------------- /07-recipe/04-recipe-view.md: -------------------------------------------------------------------------------- 1 | # 菜谱视图 2 | 3 | 我们执行 [`mix phoenix.gen.html` 命令](https://github.com/phoenixframework/phoenix/blob/master/lib/mix/tasks/phoenix.gen.html.ex#L14)时,它会生成如下文件: 4 | 5 | * a schema in web/models 6 | * a view in web/views 7 | * a controller in web/controllers 8 | * a migration file for the repository 9 | * default CRUD templates in web/templates 10 | * test files for generated model and controller 11 | 12 | 其中有两个测试文件,但是没有视图的测试文件 - 为什么?难道视图不重要?不不不,只要是代码,都会有测试的必要 - 有时没写,只是一个优先级或是投入产出比上的考虑。那如何入手? 13 | 14 | 查看 `test/views` 目录,现在已经有三个文件。我们可以借鉴其中的 `error_view_test.exs` 文件。 15 | 16 | 首先在 `test/views` 目录下新建一个 `recipe_view_test.exs` 文件,然后准备好如下内容: 17 | 18 | ```elixir 19 | defmodule TvRecipe.RecipeViewTest do 20 | use TvRecipe.ConnCase, async: true 21 | 22 | # Bring render/3 and render_to_string/3 for testing custom views 23 | import Phoenix.View 24 | 25 | end 26 | ``` 27 | 前面章节里我们曾经提到过,templates 文件会被编译成 view 模块下的函数。比如我们的 `web/templates/recipe/index.html.eex` 文件,最终会变成 `TvRecipe.RecipeView` 模块中的一个函数: 28 | 29 | ```elixir 30 | def render("index.html", assigns) do 31 | # 返回编译后的 eex 模板 32 | end 33 | ``` 34 | 换句话说,我们其实可以在 `TvRecipe.RecipeView` 中定义 `render("index.html", assigns)`,而不必再写一个 `index.html.eex` 模板。只是模板对我们的开发更为友好,所以才从 View 中分离出来。 35 | 36 | 因此,测试模板即测试 View 中的函数。 37 | 38 | 那么,测试什么?测试我们的期望与事实是否相符。 39 | 40 | 举 `recipe/index.html.eex` 模板说,我们想在模板页面上显示哪些数据?Phoenix 最后的输出是否确保显示了? 41 | 42 | 来加一个测试: 43 | 44 | ```elixir 45 | diff --git a/test/views/recipe_view_test.exs b/test/views/recipe_view_test.exs 46 | index be4148a..8174c14 100644 47 | --- a/test/views/recipe_view_test.exs 48 | +++ b/test/views/recipe_view_test.exs 49 | @@ -4,4 +4,28 @@ defmodule TvRecipe.RecipeViewTest do 50 | # Bring render/3 and render_to_string/3 for testing custom views 51 | import Phoenix.View 52 | 53 | + alias TvRecipe.Recipe 54 | + 55 | + test "render index.html", %{conn: conn} do 56 | + recipes = [%Recipe{id: "1", name: "淘米", title: "侠饭", season: "1", episode: "1", content: "洗掉米表面的淀粉", user_id: "999"}, 57 | + %Recipe{id: "2", name: "煮饭", title: "侠饭", season: "1", episode: "1", content: "浸泡", user_id: "888"}] 58 | + content = render_to_string(TvRecipe.RecipeView, "index.html", conn: conn, recipes: recipes) 59 | + # 页面上包含标题 Listing recipes 60 | + assert String.contains?(content, "Listing recipes") 61 | + for recipe <- recipes do 62 | + # 页面上包含菜谱名 63 | + assert String.contains?(content, recipe.name) 64 | + # 页面上包含节目名 65 | + assert String.contains?(content, recipe.title) 66 | + # 包含 season 67 | + assert String.contains?(content, recipe.season) 68 | + # 包含 episode 69 | + assert String.contains?(content, recipe.episode) 70 | + # 不包含所有者 id 71 | + refute String.contains?(content, recipe.user_id) 72 | + # 因为 content 很长,我们不在 index.html 里显示 73 | + refute String.contains?(content, recipe.content) 74 | + end 75 | + end 76 | + 77 | end 78 | ``` 79 | 你可能要问,测试里的 `season` 为什么是 "1" 而不是 1,要知道我们给它定义的类型是 `integer`。 80 | 81 | 是的,我们本应该把它写为 1,但那样的话,我们的测试代码里就要做类型转换: 82 | 83 | ```elixir 84 | assert String.contains?(content, Integer.to_string(recipe.season)) 85 | ``` 86 | 为了省事,我们就直接把 1 写成了 "1" - 这并不会有问题,因为 Phoenix 在编译模板时,也会做[类型转换](https://hexdocs.pm/eex/EEx.html#eval_string/3): 87 | 88 | ```bash 89 | iex> EEx.eval_string "foo <%= bar %>", [bar: 123] 90 | "foo 123" 91 | ``` 92 | 运行测试: 93 | 94 | ```bash 95 | $ mix test 96 | Compiling 1 file (.ex) 97 | ... 98 | 99 | 1) test render index.html (TvRecipe.RecipeViewTest) 100 | test/views/recipe_view_test.exs:9 101 | Expected false or nil, got true 102 | code: String.contains?(content, recipe.user_id()) 103 | stacktrace: 104 | test/views/recipe_view_test.exs:25: anonymous fn/3 in TvRecipe.RecipeViewTest.test render index.html/1 105 | (elixir) lib/enum.ex:1755: Enum."-reduce/3-lists^foldl/2-0-"/3 106 | test/views/recipe_view_test.exs:15: (test) 107 | 108 | .................................................... 109 | 110 | Finished in 0.8 seconds 111 | 58 tests, 1 failure 112 | ``` 113 | 一个错误发生,因为目前的 `index.html.eex` 中包含了 `content` 与 `user_id` 的内容。 114 | 115 | 我们调整下 `index.html.eex` 文件: 116 | 117 | ```eex 118 | diff --git a/web/templates/recipe/index.html.eex b/web/templates/recipe/index.html.eex 119 | index b6ff40b..1dc8a3a 100644 120 | --- a/web/templates/recipe/index.html.eex 121 | +++ b/web/templates/recipe/index.html.eex 122 | @@ -7,8 +7,6 @@ 123 | Title 124 | Season 125 | Episode 126 | - Content 127 | - User 128 | 129 | 130 | 131 | @@ -20,8 +18,6 @@ 132 | <%= recipe.title %> 133 | <%= recipe.season %> 134 | <%= recipe.episode %> 135 | - <%= recipe.content %> 136 | - <%= recipe.user_id %> 137 | 138 | 139 | <%= link "Show", to: recipe_path(@conn, :show, recipe), class: "btn btn-default btn-xs" %> 140 | ``` 141 | 142 | 再运行测试: 143 | 144 | ```bash 145 | mix test 146 | ........................................................ 147 | 148 | Finished in 0.8 seconds 149 | 58 tests, 0 failures 150 | ``` 151 | 悉数通过。 152 | 153 | 我们知道,`index.html.eex` 页面上有一个 `New recipe` 的按钮,那我们的测试里是否需要体现?`Show`、`Edit`、`Delete` 这些按钮呢?要不要给它们写测试? 154 | 155 | 测试测什么,测到怎么的粒度,我觉得没有标准答案,更多时候要根据项目情况去权衡。但如果一定要有一个什么做为参考,我会选择设计稿。设计稿上定下来的元素,我们就尽量在测试里体现 - 否则设计人员很容易找上门来,说怎么少了这少了那。 156 | 157 | 在结束本节之前,别忘了在菜单栏上加上“菜谱”,不然我们就只能通过修改 url 访问菜谱相关页面了: 158 | 159 | ```elixir 160 | diff --git a/test/controllers/user_controller_test.exs b/test/controllers/user_controller_test.exs 161 | index a1b75c6..7bd839c 100644 162 | --- a/test/controllers/user_controller_test.exs 163 | +++ b/test/controllers/user_controller_test.exs 164 | @@ -29,6 +29,7 @@ defmodule TvRecipe.UserControllerTest do 165 | # 注册后自动登录,检查首页是否包含用户名 166 | conn = get conn, page_path(conn, :index) 167 | assert html_response(conn, 200) =~ Map.get(@valid_attrs, :username) 168 | + assert html_response(conn, 200) =~ "菜谱" 169 | end 170 | 171 | test "does not create resource and renders errors when data is invalid", %{conn: conn} do 172 | diff --git a/web/templates/layout/app.html.eex b/web/templates/layout/app.html.eex 173 | index b13f370..49240c9 100644 174 | --- a/web/templates/layout/app.html.eex 175 | +++ b/web/templates/layout/app.html.eex 176 | @@ -19,6 +19,7 @@ 177 |
  • Get Started
  • 178 | <%= if @current_user do %> 179 |
  • <%= link @current_user.username, to: user_path(@conn, :show, @current_user) %>
  • 180 | +
  • <%= link "菜谱", to: recipe_path(@conn, :index) %>
  • 181 |
  • <%= link "退出", to: session_path(@conn, :delete, @current_user), method: "delete" %>
  • 182 | <% else %> 183 |
  • <%= link "登录", to: session_path(@conn, :new) %>
  • 184 | ``` 185 | 186 | 运行测试: 187 | 188 | ```bash 189 | $ mix test 190 | .......................................................... 191 | 192 | Finished in 0.8 seconds 193 | 58 tests, 0 failures 194 | ``` -------------------------------------------------------------------------------- /07-recipe/05-recipe-tv-url.md: -------------------------------------------------------------------------------- 1 | # 添加视频地址 2 | 3 | 我们的菜谱现在有对应的节目名、哪一季哪一集,如果能直接附上视频的链接,就更完美了。 4 | 5 | 我们需要一个数据库迁移(migration)文件: 6 | 7 | ```bash 8 | $ mix ecto.gen.migration add_url_to_recipe 9 | * creating priv/repo/migrations 10 | * creating priv/repo/migrations/20170211030550_add_url_to_recipe.exs 11 | ``` 12 | 修改新建的迁移文件: 13 | 14 | ```elixir 15 | diff --git a/priv/repo/migrations/20170211030550_add_url_to_recipe.exs b/priv/repo/migrations/20170211030550_add_url_to_recipe.exs 16 | index 01e5f17..f0918c6 100644 17 | --- a/priv/repo/migrations/20170211030550_add_url_to_recipe.exs 18 | +++ b/priv/repo/migrations/20170211030550_add_url_to_recipe.exs 19 | @@ -2,6 +2,8 @@ defmodule TvRecipe.Repo.Migrations.AddUrlToRecipe do 20 | use Ecto.Migration 21 | 22 | def change do 23 | - 24 | + alter table(:recipes) do 25 | + add :url, :string 26 | + end 27 | end 28 | end 29 | ``` 30 | 接着将 `:url` 加入 schema 中: 31 | 32 | ```elixir 33 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex 34 | index 230f290..104db50 100644 35 | --- a/web/models/recipe.ex 36 | +++ b/web/models/recipe.ex 37 | @@ -6,6 +6,7 @@ defmodule TvRecipe.Recipe do 38 | field :title, :string 39 | field :season, :integer, default: 1 40 | field :episode, :integer, default: 1 41 | + field :url, :string 42 | field :content, :string 43 | belongs_to :user, TvRecipe.User 44 | 45 | @@ -17,7 +18,7 @@ defmodule TvRecipe.Recipe do 46 | """ 47 | def changeset(struct, params \\ %{}) do 48 | struct 49 | - |> cast(params, [:name, :title, :season, :episode, :content]) 50 | + |> cast(params, [:name, :title, :season, :episode, :content, :url]) 51 | |> validate_required([:name, :title, :season, :episode, :content], message: "请填写") 52 | |> validate_number(:season, greater_than: 0, message: "请输入大于 0 的数字") 53 | |> validate_number(:episode, greater_than: 0, message: "请输入大于 0 的数字") 54 | ``` 55 | 56 | 最后,执行 `mix ecto.migrate`: 57 | 58 | ```bash 59 | $ mix ecto.migrate 60 | Compiling 13 files (.ex) 61 | 62 | 11:53:37.646 [info] == Running TvRecipe.Repo.Migrations.AddUrlToRecipe.change/0 forward 63 | 64 | 11:53:37.646 [info] alter table recipes 65 | 66 | 11:53:37.676 [info] == Migrated in 0.0s 67 | ``` 68 | 接着新增一个测试,我们需要验证 url 的有效性: 69 | 70 | ```elixir 71 | diff --git a/test/models/recipe_test.exs b/test/models/recipe_test.exs 72 | index 8b093ed..f1ba3f9 100644 73 | --- a/test/models/recipe_test.exs 74 | +++ b/test/models/recipe_test.exs 75 | @@ -60,4 +60,9 @@ defmodule TvRecipe.RecipeTest do 76 | assert {:user_id, "does not exist"} in errors_on(changeset) 77 | end 78 | 79 | + test "url should be valid" do 80 | + attrs = Map.put(@valid_attrs, :url, "fjsalfa") 81 | + assert {:url, "url 错误"} in errors_on(%Recipe{}, attrs) 82 | + end 83 | + 84 | end 85 | ``` 86 | 运行测试: 87 | 88 | ```bash 89 | mix test 90 | ............................... 91 | 92 | 1) test url should be valid (TvRecipe.RecipeTest) 93 | test/models/recipe_test.exs:63 94 | Assertion with in failed 95 | code: {:url, "url 错误"} in errors_on(%Recipe{}, attrs) 96 | left: {:url, "url 错误"} 97 | right: [] 98 | stacktrace: 99 | test/models/recipe_test.exs:65: (test) 100 | 101 | ........................... 102 | 103 | Finished in 1.0 seconds 104 | 59 tests, 1 failure 105 | ``` 106 | 那么我们要如何在 `recipe.ex` 文件中验证 url 的有效性? 107 | 108 | 我们可以考虑用正则表达式配合 `validate_format`,但有个更好的办法,是直接引用 Erlang 的方法: 109 | 110 | ```elixir 111 | diff --git a/web/models/recipe.ex b/web/models/recipe.ex 112 | index 104db50..3b849c8 100644 113 | --- a/web/models/recipe.ex 114 | +++ b/web/models/recipe.ex 115 | @@ -22,6 +22,16 @@ defmodule TvRecipe.Recipe do 116 | |> validate_required([:name, :title, :season, :episode, :content], message: "请填写") 117 | |> validate_number(:season, greater_than: 0, message: "请输入大于 0 的数字") 118 | |> validate_number(:episode, greater_than: 0, message: "请输入大于 0 的数字") 119 | + |> validate_url(:url) 120 | |> foreign_key_constraint(:user_id) 121 | end 122 | + 123 | + defp validate_url(changeset, field, _options \\ []) do 124 | + validate_change changeset, field, fn _, url -> 125 | + case url |> String.to_charlist |> :http_uri.parse do 126 | + {:ok, _} -> [] 127 | + {:error, _} -> [url: "url 错误"] 128 | + end 129 | + end 130 | + end 131 | end 132 | ``` 133 | 我们在 `recipe.ex` 文件中新增了一个 `validate_url` 私有方法,并调用 Ecto 提供的 [validate_change](https://hexdocs.pm/ecto/Ecto.Changeset.html#validate_change/3) 函数来验证属性是否有效。[http_uri](http://erlang.org/doc/man/http_uri.html) 是 Erlang 的模块,在 Elixir 中,我们能够以 `:http_uri` 的形式调用。 134 | 135 | 我们所有的 recipe 模板都需要做调整 - 此时,我想你可能已经意识到测试驱动的好处了,如果我们给各个模板添加过测试,那么有新特性加入时,我们先在测试中体现我们的目的,然后运行测试,就知道需要修改哪些文件来达到我们的目的。 136 | 137 | 参照前一节的代码,我们来进一步完善 `RecipeViewTest` 模块的代码: 138 | 139 | ```elixir 140 | diff --git a/test/views/recipe_view_test.exs b/test/views/recipe_view_test.exs 141 | index 8174c14..9695647 100644 142 | --- a/test/views/recipe_view_test.exs 143 | +++ b/test/views/recipe_view_test.exs 144 | @@ -28,4 +28,23 @@ defmodule TvRecipe.RecipeViewTest do 145 | end 146 | end 147 | 148 | + test "render new.html", %{conn: conn} do 149 | + changeset = Recipe.changeset(%Recipe{}) 150 | + content = render_to_string(TvRecipe.RecipeView, "new.html", conn: conn, changeset: changeset) 151 | + assert String.contains?(content, "url") 152 | + end 153 | + 154 | + test "render show.html", %{conn: conn} do 155 | + recipe = %Recipe{id: "1", name: "淘米", title: "侠饭", season: "1", episode: "1", content: "洗掉米表面的淀粉", user_id: "999", url: "https://github.com/chenxsan/PhoenixFramework"} 156 | + content = render_to_string(TvRecipe.RecipeView, "show.html", conn: conn, recipe: recipe) 157 | + assert String.contains?(content, recipe.url) 158 | + end 159 | + 160 | + test "render edit.html", %{conn: conn} do 161 | + recipe = %Recipe{id: "1", name: "淘米", title: "侠饭", season: "1", episode: "1", content: "洗掉米表面的淀粉", user_id: "999", url: "https://github.com/chenxsan/PhoenixFramework"} 162 | + changeset = Recipe.changeset(recipe) 163 | + content = render_to_string(TvRecipe.RecipeView, "edit.html", conn: conn, changeset: changeset, recipe: recipe) 164 | + assert String.contains?(content, recipe.url) 165 | + end 166 | + 167 | end 168 | ``` 169 | 然后运行测试: 170 | 171 | ```bash 172 | mix test 173 | Compiling 1 file (.ex) 174 | ... 175 | 176 | 1) test render new.html (TvRecipe.RecipeViewTest) 177 | test/views/recipe_view_test.exs:31 178 | Expected truthy, got false 179 | code: String.contains?(content, "url") 180 | stacktrace: 181 | test/views/recipe_view_test.exs:34: (test) 182 | 183 | 184 | 185 | 2) test render show.html (TvRecipe.RecipeViewTest) 186 | test/views/recipe_view_test.exs:37 187 | Expected truthy, got false 188 | code: String.contains?(content, recipe.url()) 189 | stacktrace: 190 | test/views/recipe_view_test.exs:40: (test) 191 | 192 | . 193 | 194 | 3) test render edit.html (TvRecipe.RecipeViewTest) 195 | test/views/recipe_view_test.exs:43 196 | Expected truthy, got false 197 | code: String.contains?(content, recipe.url()) 198 | stacktrace: 199 | test/views/recipe_view_test.exs:47: (test) 200 | 201 | ....................................................... 202 | 203 | Finished in 0.9 seconds 204 | 62 tests, 3 failures 205 | ``` 206 | 根据测试结果,我们修改文件: 207 | 208 | ```elixir 209 | diff --git a/web/templates/recipe/form.html.eex b/web/templates/recipe/form.html.eex 210 | index 3bf90ff..ab12be3 100644 211 | --- a/web/templates/recipe/form.html.eex 212 | +++ b/web/templates/recipe/form.html.eex 213 | @@ -30,6 +30,12 @@ 214 |
    215 | 216 |
    217 | + <%= label f, :url, class: "control-label" %> 218 | + <%= text_input f, :url, class: "form-control" %> 219 | + <%= error_tag f, :url %> 220 | +
    221 | + 222 | +
    223 | <%= label f, :content, class: "control-label" %> 224 | <%= textarea f, :content, class: "form-control" %> 225 | <%= error_tag f, :content %> 226 | diff --git a/web/templates/recipe/show.html.eex b/web/templates/recipe/show.html.eex 227 | index 3ef437d..f4ea463 100644 228 | --- a/web/templates/recipe/show.html.eex 229 | +++ b/web/templates/recipe/show.html.eex 230 | @@ -23,6 +23,11 @@ 231 | 232 | 233 |
  • 234 | + Url: 235 | + <%= @recipe.url %> 236 | +
  • 237 | + 238 | +
  • 239 | Content: 240 | <%= @recipe.content %> 241 |
  • 242 | ``` 243 | 最后再运行一次测试: 244 | 245 | ```elixir 246 | mix test 247 | Compiling 1 file (.ex) 248 | .............................................................. 249 | 250 | Finished in 0.9 seconds 251 | 62 tests, 0 failures 252 | ``` 253 | 测试全部通过。 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | Attribution-NonCommercial-NoDerivs 3.0 Unported 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS LICENSE DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE INFORMATION PROVIDED, AND DISCLAIMS LIABILITY FOR 10 | DAMAGES RESULTING FROM ITS USE. 11 | 12 | License 13 | 14 | THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE 15 | COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY 16 | COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS 17 | AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. 18 | 19 | BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE 20 | TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY 21 | BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS 22 | CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND 23 | CONDITIONS. 24 | 25 | 1. Definitions 26 | 27 | a. "Adaptation" means a work based upon the Work, or upon the Work and 28 | other pre-existing works, such as a translation, adaptation, 29 | derivative work, arrangement of music or other alterations of a 30 | literary or artistic work, or phonogram or performance and includes 31 | cinematographic adaptations or any other form in which the Work may be 32 | recast, transformed, or adapted including in any form recognizably 33 | derived from the original, except that a work that constitutes a 34 | Collection will not be considered an Adaptation for the purpose of 35 | this License. For the avoidance of doubt, where the Work is a musical 36 | work, performance or phonogram, the synchronization of the Work in 37 | timed-relation with a moving image ("synching") will be considered an 38 | Adaptation for the purpose of this License. 39 | b. "Collection" means a collection of literary or artistic works, such as 40 | encyclopedias and anthologies, or performances, phonograms or 41 | broadcasts, or other works or subject matter other than works listed 42 | in Section 1(f) below, which, by reason of the selection and 43 | arrangement of their contents, constitute intellectual creations, in 44 | which the Work is included in its entirety in unmodified form along 45 | with one or more other contributions, each constituting separate and 46 | independent works in themselves, which together are assembled into a 47 | collective whole. A work that constitutes a Collection will not be 48 | considered an Adaptation (as defined above) for the purposes of this 49 | License. 50 | c. "Distribute" means to make available to the public the original and 51 | copies of the Work through sale or other transfer of ownership. 52 | d. "Licensor" means the individual, individuals, entity or entities that 53 | offer(s) the Work under the terms of this License. 54 | e. "Original Author" means, in the case of a literary or artistic work, 55 | the individual, individuals, entity or entities who created the Work 56 | or if no individual or entity can be identified, the publisher; and in 57 | addition (i) in the case of a performance the actors, singers, 58 | musicians, dancers, and other persons who act, sing, deliver, declaim, 59 | play in, interpret or otherwise perform literary or artistic works or 60 | expressions of folklore; (ii) in the case of a phonogram the producer 61 | being the person or legal entity who first fixes the sounds of a 62 | performance or other sounds; and, (iii) in the case of broadcasts, the 63 | organization that transmits the broadcast. 64 | f. "Work" means the literary and/or artistic work offered under the terms 65 | of this License including without limitation any production in the 66 | literary, scientific and artistic domain, whatever may be the mode or 67 | form of its expression including digital form, such as a book, 68 | pamphlet and other writing; a lecture, address, sermon or other work 69 | of the same nature; a dramatic or dramatico-musical work; a 70 | choreographic work or entertainment in dumb show; a musical 71 | composition with or without words; a cinematographic work to which are 72 | assimilated works expressed by a process analogous to cinematography; 73 | a work of drawing, painting, architecture, sculpture, engraving or 74 | lithography; a photographic work to which are assimilated works 75 | expressed by a process analogous to photography; a work of applied 76 | art; an illustration, map, plan, sketch or three-dimensional work 77 | relative to geography, topography, architecture or science; a 78 | performance; a broadcast; a phonogram; a compilation of data to the 79 | extent it is protected as a copyrightable work; or a work performed by 80 | a variety or circus performer to the extent it is not otherwise 81 | considered a literary or artistic work. 82 | g. "You" means an individual or entity exercising rights under this 83 | License who has not previously violated the terms of this License with 84 | respect to the Work, or who has received express permission from the 85 | Licensor to exercise rights under this License despite a previous 86 | violation. 87 | h. "Publicly Perform" means to perform public recitations of the Work and 88 | to communicate to the public those public recitations, by any means or 89 | process, including by wire or wireless means or public digital 90 | performances; to make available to the public Works in such a way that 91 | members of the public may access these Works from a place and at a 92 | place individually chosen by them; to perform the Work to the public 93 | by any means or process and the communication to the public of the 94 | performances of the Work, including by public digital performance; to 95 | broadcast and rebroadcast the Work by any means including signs, 96 | sounds or images. 97 | i. "Reproduce" means to make copies of the Work by any means including 98 | without limitation by sound or visual recordings and the right of 99 | fixation and reproducing fixations of the Work, including storage of a 100 | protected performance or phonogram in digital form or other electronic 101 | medium. 102 | 103 | 2. Fair Dealing Rights. Nothing in this License is intended to reduce, 104 | limit, or restrict any uses free from copyright or rights arising from 105 | limitations or exceptions that are provided for in connection with the 106 | copyright protection under copyright law or other applicable laws. 107 | 108 | 3. License Grant. Subject to the terms and conditions of this License, 109 | Licensor hereby grants You a worldwide, royalty-free, non-exclusive, 110 | perpetual (for the duration of the applicable copyright) license to 111 | exercise the rights in the Work as stated below: 112 | 113 | a. to Reproduce the Work, to incorporate the Work into one or more 114 | Collections, and to Reproduce the Work as incorporated in the 115 | Collections; and, 116 | b. to Distribute and Publicly Perform the Work including as incorporated 117 | in Collections. 118 | 119 | The above rights may be exercised in all media and formats whether now 120 | known or hereafter devised. The above rights include the right to make 121 | such modifications as are technically necessary to exercise the rights in 122 | other media and formats, but otherwise you have no rights to make 123 | Adaptations. Subject to 8(f), all rights not expressly granted by Licensor 124 | are hereby reserved, including but not limited to the rights set forth in 125 | Section 4(d). 126 | 127 | 4. Restrictions. The license granted in Section 3 above is expressly made 128 | subject to and limited by the following restrictions: 129 | 130 | a. You may Distribute or Publicly Perform the Work only under the terms 131 | of this License. You must include a copy of, or the Uniform Resource 132 | Identifier (URI) for, this License with every copy of the Work You 133 | Distribute or Publicly Perform. You may not offer or impose any terms 134 | on the Work that restrict the terms of this License or the ability of 135 | the recipient of the Work to exercise the rights granted to that 136 | recipient under the terms of the License. You may not sublicense the 137 | Work. You must keep intact all notices that refer to this License and 138 | to the disclaimer of warranties with every copy of the Work You 139 | Distribute or Publicly Perform. When You Distribute or Publicly 140 | Perform the Work, You may not impose any effective technological 141 | measures on the Work that restrict the ability of a recipient of the 142 | Work from You to exercise the rights granted to that recipient under 143 | the terms of the License. This Section 4(a) applies to the Work as 144 | incorporated in a Collection, but this does not require the Collection 145 | apart from the Work itself to be made subject to the terms of this 146 | License. If You create a Collection, upon notice from any Licensor You 147 | must, to the extent practicable, remove from the Collection any credit 148 | as required by Section 4(c), as requested. 149 | b. You may not exercise any of the rights granted to You in Section 3 150 | above in any manner that is primarily intended for or directed toward 151 | commercial advantage or private monetary compensation. The exchange of 152 | the Work for other copyrighted works by means of digital file-sharing 153 | or otherwise shall not be considered to be intended for or directed 154 | toward commercial advantage or private monetary compensation, provided 155 | there is no payment of any monetary compensation in connection with 156 | the exchange of copyrighted works. 157 | c. If You Distribute, or Publicly Perform the Work or Collections, You 158 | must, unless a request has been made pursuant to Section 4(a), keep 159 | intact all copyright notices for the Work and provide, reasonable to 160 | the medium or means You are utilizing: (i) the name of the Original 161 | Author (or pseudonym, if applicable) if supplied, and/or if the 162 | Original Author and/or Licensor designate another party or parties 163 | (e.g., a sponsor institute, publishing entity, journal) for 164 | attribution ("Attribution Parties") in Licensor's copyright notice, 165 | terms of service or by other reasonable means, the name of such party 166 | or parties; (ii) the title of the Work if supplied; (iii) to the 167 | extent reasonably practicable, the URI, if any, that Licensor 168 | specifies to be associated with the Work, unless such URI does not 169 | refer to the copyright notice or licensing information for the Work. 170 | The credit required by this Section 4(c) may be implemented in any 171 | reasonable manner; provided, however, that in the case of a 172 | Collection, at a minimum such credit will appear, if a credit for all 173 | contributing authors of Collection appears, then as part of these 174 | credits and in a manner at least as prominent as the credits for the 175 | other contributing authors. For the avoidance of doubt, You may only 176 | use the credit required by this Section for the purpose of attribution 177 | in the manner set out above and, by exercising Your rights under this 178 | License, You may not implicitly or explicitly assert or imply any 179 | connection with, sponsorship or endorsement by the Original Author, 180 | Licensor and/or Attribution Parties, as appropriate, of You or Your 181 | use of the Work, without the separate, express prior written 182 | permission of the Original Author, Licensor and/or Attribution 183 | Parties. 184 | d. For the avoidance of doubt: 185 | 186 | i. Non-waivable Compulsory License Schemes. In those jurisdictions in 187 | which the right to collect royalties through any statutory or 188 | compulsory licensing scheme cannot be waived, the Licensor 189 | reserves the exclusive right to collect such royalties for any 190 | exercise by You of the rights granted under this License; 191 | ii. Waivable Compulsory License Schemes. In those jurisdictions in 192 | which the right to collect royalties through any statutory or 193 | compulsory licensing scheme can be waived, the Licensor reserves 194 | the exclusive right to collect such royalties for any exercise by 195 | You of the rights granted under this License if Your exercise of 196 | such rights is for a purpose or use which is otherwise than 197 | noncommercial as permitted under Section 4(b) and otherwise waives 198 | the right to collect royalties through any statutory or compulsory 199 | licensing scheme; and, 200 | iii. Voluntary License Schemes. The Licensor reserves the right to 201 | collect royalties, whether individually or, in the event that the 202 | Licensor is a member of a collecting society that administers 203 | voluntary licensing schemes, via that society, from any exercise 204 | by You of the rights granted under this License that is for a 205 | purpose or use which is otherwise than noncommercial as permitted 206 | under Section 4(b). 207 | e. Except as otherwise agreed in writing by the Licensor or as may be 208 | otherwise permitted by applicable law, if You Reproduce, Distribute or 209 | Publicly Perform the Work either by itself or as part of any 210 | Collections, You must not distort, mutilate, modify or take other 211 | derogatory action in relation to the Work which would be prejudicial 212 | to the Original Author's honor or reputation. 213 | 214 | 5. Representations, Warranties and Disclaimer 215 | 216 | UNLESS OTHERWISE MUTUALLY AGREED BY THE PARTIES IN WRITING, LICENSOR 217 | OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY 218 | KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, 219 | INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, 220 | FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF 221 | LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, 222 | WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION 223 | OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU. 224 | 225 | 6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE 226 | LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR 227 | ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES 228 | ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS 229 | BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 230 | 231 | 7. Termination 232 | 233 | a. This License and the rights granted hereunder will terminate 234 | automatically upon any breach by You of the terms of this License. 235 | Individuals or entities who have received Collections from You under 236 | this License, however, will not have their licenses terminated 237 | provided such individuals or entities remain in full compliance with 238 | those licenses. Sections 1, 2, 5, 6, 7, and 8 will survive any 239 | termination of this License. 240 | b. Subject to the above terms and conditions, the license granted here is 241 | perpetual (for the duration of the applicable copyright in the Work). 242 | Notwithstanding the above, Licensor reserves the right to release the 243 | Work under different license terms or to stop distributing the Work at 244 | any time; provided, however that any such election will not serve to 245 | withdraw this License (or any other license that has been, or is 246 | required to be, granted under the terms of this License), and this 247 | License will continue in full force and effect unless terminated as 248 | stated above. 249 | 250 | 8. Miscellaneous 251 | 252 | a. Each time You Distribute or Publicly Perform the Work or a Collection, 253 | the Licensor offers to the recipient a license to the Work on the same 254 | terms and conditions as the license granted to You under this License. 255 | b. If any provision of this License is invalid or unenforceable under 256 | applicable law, it shall not affect the validity or enforceability of 257 | the remainder of the terms of this License, and without further action 258 | by the parties to this agreement, such provision shall be reformed to 259 | the minimum extent necessary to make such provision valid and 260 | enforceable. 261 | c. No term or provision of this License shall be deemed waived and no 262 | breach consented to unless such waiver or consent shall be in writing 263 | and signed by the party to be charged with such waiver or consent. 264 | d. This License constitutes the entire agreement between the parties with 265 | respect to the Work licensed here. There are no understandings, 266 | agreements or representations with respect to the Work not specified 267 | here. Licensor shall not be bound by any additional provisions that 268 | may appear in any communication from You. This License may not be 269 | modified without the mutual written agreement of the Licensor and You. 270 | e. The rights granted under, and the subject matter referenced, in this 271 | License were drafted utilizing the terminology of the Berne Convention 272 | for the Protection of Literary and Artistic Works (as amended on 273 | September 28, 1979), the Rome Convention of 1961, the WIPO Copyright 274 | Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 275 | and the Universal Copyright Convention (as revised on July 24, 1971). 276 | These rights and subject matter take effect in the relevant 277 | jurisdiction in which the License terms are sought to be enforced 278 | according to the corresponding provisions of the implementation of 279 | those treaty provisions in the applicable national law. If the 280 | standard suite of rights granted under applicable copyright law 281 | includes additional rights not granted under this License, such 282 | additional rights are deemed to be included in the License; this 283 | License is not intended to restrict the license of any rights under 284 | applicable law. 285 | 286 | 287 | Creative Commons Notice 288 | 289 | Creative Commons is not a party to this License, and makes no warranty 290 | whatsoever in connection with the Work. Creative Commons will not be 291 | liable to You or any party on any legal theory for any damages 292 | whatsoever, including without limitation any general, special, 293 | incidental or consequential damages arising in connection to this 294 | license. Notwithstanding the foregoing two (2) sentences, if Creative 295 | Commons has expressly identified itself as the Licensor hereunder, it 296 | shall have all rights and obligations of Licensor. 297 | 298 | Except for the limited purpose of indicating to the public that the 299 | Work is licensed under the CCPL, Creative Commons does not authorize 300 | the use by either party of the trademark "Creative Commons" or any 301 | related trademark or logo of Creative Commons without the prior 302 | written consent of Creative Commons. Any permitted use will be in 303 | compliance with Creative Commons' then-current trademark usage 304 | guidelines, as may be published on its website or otherwise made 305 | available upon request from time to time. For the avoidance of doubt, 306 | this trademark restriction does not form part of this License. 307 | 308 | Creative Commons may be contacted at https://creativecommons.org/. 309 | -------------------------------------------------------------------------------- /img/02-help-page-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/02-help-page-screenshot.png -------------------------------------------------------------------------------- /img/02-mix-phoenix.gen.html.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/02-mix-phoenix.gen.html.png -------------------------------------------------------------------------------- /img/02-phoenixframework-page-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/02-phoenixframework-page-screenshot.png -------------------------------------------------------------------------------- /img/04-iex-shell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-iex-shell.png -------------------------------------------------------------------------------- /img/04-password-input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-password-input.png -------------------------------------------------------------------------------- /img/04-user-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-user-list.png -------------------------------------------------------------------------------- /img/04-username-has-error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-username-has-error.png -------------------------------------------------------------------------------- /img/04-users-blank-username.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-users-blank-username.png -------------------------------------------------------------------------------- /img/04-users-new-page.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/04-users-new-page.png -------------------------------------------------------------------------------- /img/05-login-user-http.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/05-login-user-http.png -------------------------------------------------------------------------------- /img/07-generate-recipe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/07-generate-recipe.png -------------------------------------------------------------------------------- /img/alipay-qr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chenxsan/PhoenixFramework/c6639281018c1b67188ad9b9bbb6af081b5287c7/img/alipay-qr.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Phoenix Framework 中文入门教程 2 | 3 | 前端开发一直都是我的主业。是的,我是在暗示你:**Phoenix Framework 非常容易上手**。不过也因为我一直在从事前端开发,后端的一些概念我可能理解得不够到位,如有错误,欢迎指正。 4 | 5 | ## 目录 6 | 7 | 0. [准备工作](00-prepare.md) 8 | 1. [创建项目](01-create-project.md) 9 | 2. [Phoenix 初体验](02-explore-phoenix.md) 10 | 3. [Menu 项目规划](03-project-menu.md) 11 | 12 | ## 捐款 13 | 14 | 如果教程对你有很大帮助,并且你对捐款没有心理阴影的话,欢迎扫描下方的支付宝二维码: 15 | 16 | 支付宝捐款 17 | 18 | ## License & Copyright 19 | 20 | © 2017 陈三 21 | 22 | 知识共享许可协议
    本作品采用知识共享署名-禁止演绎 4.0 国际许可协议进行许可。 --------------------------------------------------------------------------------