现有的 Web API 框架并不关注文档的问题,文档往往是作为插件挂载到框架上的。但是,文档和业务实现并不需要割裂开,它们在很大程度上应该是耦合在一起的。比方说,某个接口我定义了参数如此,就该自动生成一致的文档向前端告知;同样,当我提供了文档是如此后,我的接口实现就该自动地约束为这样实现。
Meta 框架天生就是将文档和实现统一起来的,并始终致力于此(如果真的有什么不一致或者不到位的地方,那只能说框架实现上尚有欠缺,并不能从思想上说本该如此)。Meta 框架与 Swagger 合作,致力于产生符合 Restful 和社区规范的文档格式。它提供了几乎完整的描述接口信息的宏命令,并且在描述接口的同时就能基本实现接口的一些约束,其中最重要的莫过于对参数和返回值的声明。
在正式阅读本教程之前,有一些准备工作需要提前了解的。
只接受格式为 application/json
的请求体参数,并且响应实体的格式一律为 application/json
.
这在当前的环境下并不算太大的限制,如果你是致力于新项目的开发的话。但是,如果你处理旧项目,并且要求格式为 application/json
之外的格式,如 application/xml
,则框架目前是不能自动处理的。
这种限制只存在于通过 params
宏和 status
宏设定了请求体和响应体格式的情况。如果你没有用到这两个宏,那么你还是可以自由定义请求体和响应体的格式,只不过缺少了文档化的支持。自由实现需要用到 Rack::Request
类和 Rack::Response
类提供的方法,这是 Rack 架构提供的两个包装类,用于简化 HTTP 请求和响应操作。
首先,你将学到定义路由的全部知识。换句话说,你该如何具体地描述一个接口。一般来说,我们需要描述接口的标题、详述、标签、参数和返回值。
然后,你将学到命名空间的概念。命名空间用来组织接口的层级结构,并且会用到诸如如路由嵌套、before/after 钩子、异常拦截等概念。
从命名空间引申出的模块的概念也很重要。模块本身也是一个命名空间,命名空间用到的功能都可以用在模块中。除此之外,模块还用来组织大型应用的结构。最后,模块本身也是一个 Rack 应用,可以直接放在服务器下运行。
接下来是重点,我们将深入参数和返回值的定义。虽然说前面已经提到参数和返回值的知识,但仅覆盖最简单同时也是最常用的场景。参数和返回值的知识实在是太大了,有必要专门划出一个章节来介绍它。这里提一下,参数和返回值在 Meta 框架里都统一为一个叫做实体的概念,因此你只需要学会定义一种就能够同时定义两者了。
最后,将是一个生成文档的方法。虽然它很简单,仅仅是一个方法,但它如此重要以至于我不得不专门划出一个章节来强调它的重要性。
文章的最后是特殊用法举例。说实话,我还没想好把它放哪,但它确实列举了几个比较常见的场景。
在使用 Meta 框架提供的组件时,我们往往先要继承一个类,然后直接在类定义中使用宏命令。所谓的宏命令其实就是一个 Ruby 方法,只不过在 DSL 术语中我们将它称为“宏”。
例如,定义一个 API,我们继承的是 Meta::Application
类,然后在类中使用 route
宏定义路由:
class DemoApp < Meta::Application
route '/foo', :get do
# ... 具体的宏定义
end
end
再比如继承 Meta::Entity
定义一个实体,实体内使用 property
宏定义属性:
class UserEntity < Meta::Entity
property :name
property :age
end
在 Meta::Application
类内,第一个能做的事情就是定义路由。route
方法(以后我们称这种特定的 DSL 方法为“宏”)定义一个具体的路由(即接口):
class DemoApp < Meta::Application
route '/', :get do
# 块内定义路由的详细元素
end
end
route
方法接受一个路径字符串和一个 HTTP 方法,并且可接受一个块用于定义路由的详细元素(将在后面讲到)。HTTP 方法我们一共支持五种,包括 get
、post
、put
、patch
、delete
. 为此,我们提供了五个便捷方法用于简化 route
方法调用的书写,举例:
class DemoApp < Meta::Application
get do # 当路径为 `/` 时,路径参数可以省略
# ...
end
post '/foo' do
# ...
end
put '/foo/bar' do
# ...
end
patch '/foo/bar' do
# ...
end
delete '/foo/bar' do
# ...
end
end
因为这种写法更为清晰并且视觉效果更好,教程的以后都用
get
、post
、put
、patch
、delete
五个方法代替route
方法的调用。除非是只用到路径而不关心 HTTP 方法的情形。
当定义路由 route /foo/bar
时,它匹配的是完整的路径 /foo/bar
. 当你需要匹配一堆路径时,需要为路由加上通配符符号。:
和 *
是通配符符号的两种,前者匹配一个部分,后者尽可能多地匹配剩余的部份。这么说如果没说清楚,我举两个例子即可明白:
/foo/:id
:它将匹配诸如/foo/1
、/foo/bar
等路径,但不能匹配/foo/a/b/c
这样的路径。/foo/*path
:它可以匹配/foo
、/foo/bar
、/foo/a/b/c
等格式的路径。
通配符符号后面的单词(
id
和path
)是参数名称,它将路由中与其匹配的部分放到参数中可访问。这里先提一下,通过request.params['id']
、request.params['path']
可以访问到路由当中匹配的部分。
如果你不需要后续访问到参数,可以忽略命名:
/foo/:
/foo/*
再举两个路由参数的示例:
/foo/:id/bar
:匹配诸如/foo/1/bar
、/foo/2/bar
等路径/foo/*/bar
:匹配/foo/bar
、/foo/a/bar
、/foo/a/b/bar
、/foo/a/b/c/bar
等格式的路径。
在 route
宏内部,可使用两个宏: meta
宏定义路由的元信息,action
宏定义路由的执行逻辑。
首先,通过 meta
宏定义路由的“元”信息。注意,“元”信息的作用是双向的,既可以定义接口的文档,也可以约束接口的行为。例如,在 meta
宏内定义参数:
post '/users' do
meta do
params do
param :name, type: 'string', description: '姓名'
param :age, type: 'integer', description: '年龄'
end
end
end
它会产生两个方面的效果:
- 文档方面:接口文档的参数部分会暴露出两个参数:
name
、age
,并声明它的类型和描述信息。 - 业务逻辑方面:业务代码执行时,通过标准的方法获取参数时会对参数作校验。这里面它只会提取出参数的两个字段(
name
和age
),并对它们俩的类型作校验。如果参数不符合定义,会向客户端抛出一个错误。
meta
宏内部现在只提供了以下五个方法:
post '/users' do
meta do
title '创建用户'
description '接口的详细描述'
tags ['User'] # 定义接口的 Tag,传递一个数组
params do
# 内部定义参数结构
end
status 200 do
# 内部定义返回值结构
end
end
end
以上,title
、description
、tags
宏分别定义接口的标题、描述信息和标签列表。params
和 status
宏定义接口的参数和返回值,其内部定义比较复杂,将在后面详细讲解。
meta
宏可以展开定义,亦即可以直接在 route
定义内部直接使用 meta
宏定义的语法,它是 route
定义内部提供的一种快捷方式:
post '/users' do
title '创建用户'
description '接口的详细描述'
tags ['User'] # 定义接口的 Tag,传递一个数组
params do
# 内部定义参数结构
end
status 200 do
# 内部定义返回值结构
end
end
由于展开定义的方式写起来更加便捷,因此后面的教程示例都将采取这样的写法。
action
宏定义业务代码部分。将上面的 POST /users
接口的逻辑实现定义完全,大概率是以下这个样子:
post '/users' do
# ... 定义路由的 meta 部分
action do
user = User.create!(params[:user])
render :user, user
end
end
其中,用到的 params
方法和 render
方法将在后面讲到。
class DemoApp < Meta::Application
get do # 匹配 GET /
# ...
end
namespace '/foo' do
get do # 匹配 GET /foo
# ...
end
post '/bar' do # 匹配 POST /foo/bar
# ...
end
namespace '/baz' do
# ... 匹配前缀为 /foo/baz
end
end
end
namespace
宏是定义父级结构的,它不能定义到具体的路由,它的内部需要有 namespace
宏或 route
宏定义更具体的结点。namespace
宏匹配的是路径的前缀。
而 route
宏是最深层次的结构,它定义的是具体的路由和它的行为,它的内部不能有 namesapce
宏及 route
宏。route
宏要匹配完整的路径。
为什么要定义一个
namespace
宏,它不仅仅是减少路径的重复代码这么简单。综合来讲,namespace
宏有如下作用:
- 通过组合
namespace
和route
宏来定义应用的层次结构。namespace
内可定义钩子,用于公共的运行逻辑。- 可定义
namespace
级的异常拦截处理方法。- 在
namespace
定义meta
宏,可定义公共的“元”信息。
namesapce
内提供了两种钩子:before
、after
. 它在整个 namespace
层级执行一遍。
正如名字所表达的那样,before
在 action
宏之前执行,after
在 action
宏之后执行。
class DemoApp < Meta::Application
namespace '/foo' do
before do
puts 1
end
after do
puts 2
end
get do
action do
puts 3
end
end
put do
action do
puts 4
end
end
end
end
当用户访问 GET /foo
接口时,依次打印数字 1
、3
、2
;当用户访问 PUT /foo
接口时,依次打印数字 1
、4
、2
.
Meta 框架同时还支持 around
钩子, around
钩子会包裹 action
执行:
class DemoApp < Meta::Application
namespace '/foo' do
around do |next_action|
puts 1
next_action.execute(self)
puts 2
end
get do
action do
puts 3
end
end
put do
action do
puts 4
end
end
end
end
同样的,当用户访问 GET /foo
接口时,依次打印数字 1
、3
、2
;当用户访问 PUT /foo
接口时,依次打印数字 1
、4
、2
.
around
钩子现在还处于实验阶段,不建议实际开发中使用。当around
钩子混合定义before
、after
钩子时,其执行的顺序比较混乱。而且,现在的around
钩子还无法覆盖参数解析和返回值渲染的过程,这让它们的应用范围受到限制。当需要完整覆盖接口执行的全周期时,推荐使用 Rack 的中间件。最后,一定要在恰当的时机执行next_action.execute(self)
,否则后续的动作将不会得到执行。next_action.execute(self)
调用稍显繁琐,不够优雅。
如果只包含 before
和 after
钩子,则执行的顺序是:
before
钩子先执行,按照定义的顺序;- 接着执行
action
定义的块; - 最后执行
after
钩子,按照定义的顺序。
举例(以下按照 1
、2
、3
的数字顺序执行):
class DemoApp < Meta::Application
namespace '/foo' do
before { puts 1 }
before { puts 2 }
after { puts 4 }
after { puts 5 }
get '/request' do
puts 3
end
end
end
如果还包含 around
钩子,则会复杂一些,但大体上是:
- 最先执行的是
before
钩子以及around
钩子的前半部分,按照定义的顺序; - 接着执行
action
定义的块; - 然后执行
after
钩子,按照定义的顺序; - 最后执行
around
钩子的后半部分,按照定义的逆序执行。
举例(以下按照 1
、2
、3
的数字顺序执行):
class DemoApp < Meta::Application
namespace '/foo' do
before { puts 1 }
around { |next_action|
puts 2
next_action.execute(self)
puts 9
}
around { |next_action|
puts 3
next_action.execute(self)
puts 8
}
before { puts 4 }
after { puts 6 }
after { puts 7 }
get '/request' do
puts 5
end
end
end
请注意,钩子的执行顺序是严格按照以上顺序执行的,与你定义的顺序无关。请确保 before
和 around
钩子优先于 after
的顺序定义,因为它们的执行也是优先于 after
的。
另外,钩子的执行不会覆盖参数解析和返回值渲染,亦即 before
钩子在参数解析之后执行,after
钩子在返回值渲染之前执行,而 around
钩子亦不会覆盖参数解析和返回值渲染。
钩子不会中断执行。如果要在钩子中中断程序的执行,可使用 abort_execution!
方法:
before do
token = request.get_header('HTTP_X_TOKEN')
// ... parse token
rescue TokenInvalidError => e
response.status = 401
response.message = "Token 格式异常:#{e.message}"
abort_execution!
end
abort_execution!
同时会跳过返回值渲染的执行。
冷知识:Meta::Application
本身也可视为一个命名空间定义,namespace
内能用到的方法也可以在 Meta::Application
内使用。
在 namespace
中可使用 rescue_error
拦截异常。
class DemoApp < Meta::Application
namespace '/users/:id' do
rescue_error ActiveRecord::RecordNotFound do |e|
response.status = 404
response.body = ["所访问的资源不存在"]
end
get do
action do
user = User.find(params[:id])
end
end
end
end
以下是 Meta 框架抛出的异常:
Meta::Errors::NoMatchingRoute
:路由不匹配时。Meta::Errors::ParameterInvalid
:参数存在异常时。Meta::Errors::RenderingInvalid
:响应值存在异常时。Meta::Errors::UnsupportedContentType
:框架只支持application/json
的参数格式。当客户端的请求体不是这个格式时,会抛出这个错误。
拦截异常先在子作用域下拦截;如果拦截失败则继续在父作用域下拦截。下面的例子中:
class DemoApp < Meta::Application
namespace '/foo' do
rescue_error ErrorOne do
puts "rescued in /foo" #(1)
end
rescue_error ErrorTwo do
puts "rescued in /foo" #(2)
end
namespace '/bar' do
rescue_error ErrorOne do
puts "rescued in /foo/bar" #(3)
end
get do
action do
raise ErrorOne
end
end
put do
action do
raise ErrorTwo
end
end
end
end
end
调用 GET /foo/bar
请求时会在(3)处被捕获;调用 PUT /foo/bar
请求时会在(2)处被捕获。
由于框架实现的特殊性,异常 Meta::Errors::NoMatchingRoute
只会在顶层抛出。因此,只有在 namespace
的顶层捕获才有效果。
class DemoApp < Meta::Application
# 在此捕获有效
rescue_error Meta::Errors::NoMatchingRoute do |e|
response.status = 404
response.body = ["404 Not Found"]
end
namespace '/foo' do
# 在此捕获无效
rescue_error Meta::Errors::NoMatchingRoute do |e|
response.status = 404
response.body = ["404 Not Found"]
end
end
end
即使是上面的例子,调用 GET /foo/bar
请求时也只有顶层的异常拦截起了作用。
同 route
宏内,namespace
宏内部可以定义 meta
宏。namespace
定义的 meta
宏定义下属路由的公共部分,其会应用到全部子路由,除非在 route
宏内复写。
namespace '/users/:id' do
# 以下 meta 内定义的部分会应用到 GET /users/:id 和 PUT /users/:id 两个路由。
# 其中,因为 title 两个路由有重写,因此会使用两个路由自己的 title 定义。
# description 两个路由都没有独自定义,因此会统一使用 meta 中的定义。
# tags 同理,它们都挂载在同一个 Tag 下。
# params 定义比较特殊,子路由下的定义不是复写而是补充。因此 GET /users/:id 包含一个参数 id,PUT /users/:id 包含了两个参数
# id 和 user.
# status 与 params 同理,但由于子路由内没有 status 定义,从而它们两个都是使用 meta 中的定义,即返回一个 user 属性。
meta do
title '处理用户详情'
description '通过路径参数获取用户数据,并对用户数据做一定的处理,比如查看、更新'
tags ['User'] # 该 namespace 下的接口归到 User 标签下
params do # 定义共同参数
param :id
end
status 200 do # 定义共同返回值
expose :user, type: 'object'
end
end
get do
title '返回用户详情'
action do
user = User.find(params[:id]) # params 包括 id 字段
render :user, user # render 包括 user 字段
end
end
put do
title '更新用户'
params do
# 补充参数 user
param :user, type: 'object'
end
action do
user = User.find(params[:id])
user.update!(params[:user]) # params 包括 id、user 字段
render :user, user # render 包括 user 字段
end
end
end
当路由在执行过程中,会将块绑定到一个 Meta::Execution
实例中执行。before
、after
等钩子,action
定义的块内,以及异常拦截的过程中,其执行环境都会绑定到当前的 Meta::Execution
实例。
class DemoApp < Meta::Application
before do
@current_user = "Jim" # 设置一个环境变量
end
rescue_error StandardError do
p @current_user # 可以访问先前设置的实例变量
end
get '/user' do
action do
p @current_user # 可以访问先前设置的实例变量
raise # 抛出异常
end
end
end
request
方法返回 Rack::Request
实例,它是 Rack 框架提供的包装类,用于简化 HTTP 操作。
response
方法返回 Rack::Response
的实例,它是 Rack 框架的包装类,用于简化 HTTP 操作。
不要与 .params
宏所混淆,它是 Meta::Execution
提供的实例方法,返回解析后的参数。参数的解析参考 params
宏的定义。
定义实际响应体时使用 render
方法。render
方法参考 status
定义的响应体格式过滤和验证字段。
中断后续的执行。如果是在 before
块中执行这个方法,则跳过后续的 before
、action
和 after
块;如果是在 action
块中执行这个方法,则跳过 after
块。注意,这个方法会跳过响应体渲染阶段。
不要在 Meta::Application
内部定义方法,在它内部直接定义的方法应用到 Meta::Execution
实例。如果需要在当前以及后续路由用到公共的方法,可以在 shared
块内定义:
class DemoApp < Meta::Application
shared do
def current_user
@current_user
end
end
before do
@current_user = "Jim" # 设置一个环境变量
end
get '/user' do
action do
p current_user # 在路由内访问方法
end
end
namespace '/foo' do
get '/user' do
action do
p current_user # 在子命名空间内也能访问到方法
end
end
end
end
除此之外,shared
的参数也接受模块。
module HelperFoo
def foo; 'foo' end
end
module HelperBar
def bar; 'bar' end
end
class DemoApp < Meta::Application
shared HelperFoo, HelperBar
get '/user' do
action do
p foo
p bar # 可访问模块定义的方法
end
end
end
要知道 Meta::Application
,第一个事情就是它等同于 namespace
定义。像 namespace
一样,能定义路由、钩子、异常拦截的地方都可以在 Meta::Application
内直接定义。
class DemoApp < Meta::Application
# meta 定义,能应用到下属子路由的所有地方
meta do
# ...
end
# 它将捕获下属子路由的所有异常
rescue_error Exception do
# ...
end
# 钩子,最先执行
before do
# ...
end
# 钩子,最后执行
after do
# ...
end
# 定义嵌套命名空间
namespace '/...' do
# ...
end
# 也可以直接定义路由
route '/...', :post do
# ...
end
end
你可以将 Meta::Application
视为路径定义为 /
的命名空间。
遇到大型项目时,将 API 定义分离成若干个单独的文件更好的组织。做到这一点,就用到 namespace
中提供的 apply
方法。
继承自 Meta::Application
的类都是一个模块,它可以在 namespace
中被复用。
class Foo < Meta::Application
route '/foo' do
# ...
end
end
class DemoApp < Meta::Application
apply Foo
end
将定义写在一个类里,其等价于:
class DemoApp < Meta::Application
route '/foo' do
# ...
end
end
apply
方法还可跟一个参数 tags: [...]
,统一覆盖被引入的模块在渲染文档时声明的 tags
:
class OpenAPIApp < Meta::Application
apply API::Logins, tags: ['Login']
apply API::Users, tags: ['User']
apply API::Organizations, tags: ['Organization']
apply API::Projects, tags: ['Project']
apply API::Versions, tags: ['Version']
apply API::Members, tags: ['Member']
end
Meta::Application
同时也是一个 Rack 应用,将它挂载在 Rack 下可以直接作为一个服务运行。我们看一个最简单的 Meta::Application
实例:
class DemoApp < Meta::Application
route '/', :get do
title '应用的根路径'
action do
response.body = ["Hello, world!"]
end
end
end
将它挂载在 Rack 下并访问
http://localhost:9292
你将看到接口返回"Hello, world"
文本。
本节介绍参数和返回值如何定义。因为 Meta 框架在底层不区分参数和返回值,它们都统一为“实体”的概念。因此,当涉及到语法细节时,在参数、返回值、实体内都是一致的。
可以说,有关实体的定义,是 Meta 框架中细节最多的地方。在撰写这一章节的时候,我尝试写过很多遍,都无法很好地将方方面面说明清楚。我在行文时,一方面希望大家在入门的时候方便,能够很快地定义常用的用法;另一方面,也希望将所涉及的细节都能够阐述清楚,希望大家能够全面了解到 Meta 框架实体定义的方方面。现在,我只能尽可能地做到这两点,却不再强求。我将以场景的形式阐述用法,而不是孤立地介绍每个知识点。
在路由中,我们用 params
命令定义参数:
post '/users' do
params do
param :name
param :age
end
end
然后,我们可以在 action
命令中使用 params
方法访问参数。如果我们发送了一个这样的请求:
POST '/users' -d '{"name": "Jim", "age": 18}'
我们将在 action
命令中获取到这样的参数结构:
post '/users' do
action do
p params # => { name: "Jim", age: 18 }
end
end
参数解析时有自动过滤的作用,如果我们发送了一个这样的请求:
POST '/users' -d '{"name": "Jim", "foo": "foo"}'
我们将在 action
命令中获取到这样的参数结构:
post '/users' do
action do
p params # => { name: "Jim", age: nil }
end
end
可以看到,它的做法是过滤了未定义的 foo
字段,并且将没有提供的 age
字段设为 nil
.
一般我在实际项目中不会这么定义,而是更习惯将它们套在一个根字段下,这样做有利于结构的划分和后期的扩展。
post '/users' do
params do
param :user do
param :name
param :age
end
end
end
以上定义需要接收以下格式的参数:
POST '/users' -d '{
"user": {
"name": "Jim",
"age": 18
}
}'
在 action
命令中获取到的也是这样的嵌套结构:
post '/users' do
action do
p params # => { user: { name: "Jim", age: 18 } }
end
end
参数过滤也会发挥作用。如果请求参数传递的是这样的格式:
# HTTP params
{ "user": { "name": "Jim", "foo": "foo" } }
程序中获取的参数格式将是这样:
# p params
{ user: { name: "Jim", age: nil } }
如果顶层的 user
字段未提供,则整个 user
字段将设为 nil
.
**提醒:**后续我们会在代码第一行添加
# HTTP params
注释表明这是 HTTP 请求的参数格式,# p params
注释表明这是程序中获取到的参数格式。
参数提供很多约束选项,类型只是其中之一。
将以上的参数定义添加上类型定义,应当是:
params do
param :name, type: 'string'
param :age, type: 'integer'
end
类型定义首要的作用是报错,例如以下的参数格式会向客户端抛出 400 Bad Request
:
# HTTP params
{ "name": "Jim", "age": "eighteen" }
参数在进行类型校验时是宽容的。如果值能够成功转化为定义的类型,则参数校验不会报错。以下参数不会报错:
# HTTP params
{ "name": "Jim", "age": "18" }
只不过参数过滤会规范化最终参数的格式,因此以上参数在程序中会获取为:
# p params
{ name: "Jim", age: 18 }
age
字段获取到的是数字类型而非字符串类型。
对于嵌套参数,你可以定义为 object
类型。但这是没必要的,因为嵌套参数必然应当是 object
类型。以下两个参数定义等价:
params do
param :user, type: 'object' do
param :name
param :age
end
end
params do
param :user do
param :name
param :age
end
end
当定义 object
类型时,也可以不提供内部结构,此时将不再进行内部结构的过滤:
params do
param :user, type: 'object'
end
# 接受的 HTTP 请求参数
{ "user": { "name": "Jim", "age": 18 }}
{ "user": { "foo": "foo" } }
{ "user": {} }
{ "user": nil }
# 不接受的 HTTP 请求参数
{ "user": "foo" }
{ "user": 18 }
{ "user": [] }
记住,不校验和 object
类型不是一回事。不提供任何类型时,此时参数接受一切值:
params do
param :user
end
# 以下格式都接受
{ "user": { "name": "Jim", "age": 18 }}
{ "user": { "foo": "foo" } }
{ "user": {} }
{ "user": nil }
{ "user": "foo" }
{ "user": 18 }
{ "user": [] }
你可以定义为数组类型,此时参数必须接受为对象数组的格式。
params do
param :users, type: 'array' do
param :name
param :age
end
end
# 接受的参数格式
# HTTP params
{
"users": [
{ "name": "Jim", age: 18 },
{ "name": "Jack", age: 19}
]
}
有时候我们遇到数组内部不是对象的情况,这时候就不能使用嵌套定义:
params do
param :tags, type: 'array'
end
这个时候参数必须接收数组格式,但内部元素不会做校验。如果希望内部元素也要校验,用 items
选项定义内部结构:
params do
param :tags, type: 'array', items: { type: 'string' }
end
除了基本的 string
、integer
、number
、object
、array
类型外,参数还支持自定义类型。
params do
param :address, type: Address
end
这个类型定义会将属性获取的值转化为 Address
类型。(用 Address.new
方法)
一般,即使自定义类型,我们仍会控制字段的摄入:
params do
param :address, type: Address do
param :province
param :city
param :district
param :street
end
end
你能用到的 type
取值只能是以下之一:
"boolean"
"integer"
"number"
"string"
"object"
"array"
正如其名,required
作“必须”校验。先前说过,未传递的字段会被赋予 nil
. 然而,若字段被配置为 required
,则参数校验会报错。以下参数定义中,age
字段被配置为 required
:
# 参数定义
params do
param :name
param :age, required: true
end
以下请求皆会报错:
# HTTP params
POST '/users' -d '{"name": "Jim"}'
# HTTP params
POST '/users' -d '{"name": "Jim", age: null}'
**小提示:**先前说过,将参数套在一个根字段下是一个好的设计习惯。同时,将这个根字段配置为
required
也是一个好习惯。# 参数定义 params do param :user, required: true do param :name param :age end end这会杜绝传递诸如
{}
、{ "user": null }
这样的 JSON 格式。
框架自带若干参数验证配置,在本节列举。
required
可同时配置 allow_empty: true
或 allow_empty: false
用以是否接受空字符串或空数组:
params do
param :title, require: { allow_empty: false } # 不接受空字符串
param :tags, require: { allow_empty: true } # 可接受空数组
end
为字符串参数配置 format
可限制参数的格式。以下是用到的 format
示例:
# 参数定义
params do
param :date, format: /^\d{4}-\d{2}-\d{2}$/
param :mobile, format: /^1[3456789]\d{9}$/
param :email, format: /^$/
end
通过一个数组配置一个字段可接受的值:
# 参数定义
params do
param :p_state, description: '进程状态', allowable: ["idle", "pending", "running", "exited"]
param :gender, description: '性别', allowable: ["male", "female"]
endparams do
param :state, description: '进程状态', allowable: ["idle", "pending", "running", "exited"]
param :sex, description: '性别', allowable: ["male", "female"]
end
**小提示:**一直忘记说了,我们可以通过
description
选项配置字段的描述,这个描述会在生成文档时生效。
如果以上的校验均不够用,Meta 支持自定义编写校验。validate
接受一个块,当校验失败时需要主动地抛出 Meta::JsonSchema::ValidationError
:
params do
raise Meta::JsonSchema::ValidationError, '手机号格式不正确' unless value =~ /^1[3456789]\d{9}$/
end
default
选项可设置参数的默认值,当参数未提供或为 nil
时,默认值就会起作用:
params do
param :age, default: 18 # 通过值设定
param :name, default: -> { 'Jim' } # 通过块设定
end
默认情况下参数是放在 Request Body 下的(作为 application/json
的格式的一部分),但参数还可能存在于 path 或 query hash 中。使用 in
选项可以定义之,它对框架的执行没有影响,只对文档的生成产生效果。
post '/:in_path' do
params do
param :in_path, in: 'path'
param :in_query, in: 'query'
param :in_body, in: 'body'
end
end
status
宏命令用来定义返回值。你需要传递一个(或多个)状态码,并用一个同样结构的块作为实体的定义。
# 定义简单的返回实体
status 200 do
expose :name, type: 'string'
expose :age, type: 'integer'
end
# 同样支持嵌套
status 200 do
expose :user do
expose :name, type: 'string'
expose :age, type: 'integer'
end
end
# 同样支持校验,虽然我觉得校验返回值有点多此一举
status 200 do
expose :user, required: true do
expose :name, type: 'string'
expose :age, type: 'integer', required: true
end
end
参数和返回值的定义并不需要割裂开,它们在很多行为上是统一的。现在,我们分别单独定义了参数和返回值:
params do
param :user do
param :name, type: 'string'
param :age, type: 'integer'
end
end
status 200 do
expose :user do
expose :name, type: 'string'
expose :age, type: 'integer'
end
end
为将上述定义改造,内部的块可以封装在一个实体内定义:
class UserEntity < Meta::Entity
property :name, type: 'string'
property :age, type: 'integer'
end
然后在 params
和 status
内部使用 ref
引用这个实体:
params do
param :user, ref: UserEntity
end
status 200 do
expose :user, ref: UserEntity
end
我们通过继承 Meta::Entity
类定义了实体并到处引用,从而简化了代码。这在实践中是推荐的方案。鉴于参数、返回值、实体的定义语法是完全一致的,故而接下来我们将重点进入实体的讲解环节。希望读者清楚的是,以上参数介绍的语法在实体定义中是完全可用的;并且,接下来有关实体的语法也能完全运用到单独的参数和返回值定义块中。
**小提示:**我们在
params
中用param
命令定义参数字段,在status
中用expose
命令定义返回值字段,而在实体定义中这个命令变成了property
. 这里需要阐明的是,用param
、expose
还是property
只是习惯的不同而已,它们的行为都是一致的并且能够混用。例如,你完全可以在params
和status
中一律使用property
命令:params do property :user, ref: UserEntity end status 200 do property :user, ref: UserEntity end
param
和 expose
的只会作用到同层的字段,不会作用到实体内部。
数组内部也可以引用实体,只要在字段上加上 type: "array"
即可:
params do
param :users, type: "array", ref: UserEntity
end
接下来会涉及之前没提过的配置选项,包括 param
、render
、scope
、value
、convert
等。单独说明某个选项的用法显得枯燥,我接下来将以列举场景的方式说明。
由于实体内部既包括参数的字段,也包括返回值的字段,必然有某些字段只可作为参数或返回值。这种情况该如何做呢?我们可以配置 param: false
定义这个字段不可作为参数,另外配置 render: false
定义这个字段不可用作返回值。如下是一个例子:
class UserEntity < Meta::Entity
property :id, param: false # id 字段不可用作参数
property :name # name 和 age 字段既可用作参数,也可用作返回值
property :age
property :password, render: false # password 字段不可用作返回值
end
虽然有点啰嗦,上述例子如果接收的参数是:
# HTTP params
{ "id": 1, "name": "Jim", "age": 18, "password": "123456" }
则程序中获取到的参数内容是:
# p params
{ name: "Jim", age: 18, password: "123456" }
另外,如果我们渲染了如下的数据:
action do
render("id" => 1, "name" => "Jim", "age" => 18, "password" => "123456")
end
则客户端实际得到的 JSON 格式是:
# HTTP Response
{ "id": 1, "name": "Jim", "age": 18 }
param
和 render
支持两种格式:其一是刚刚见过的 false
,它将禁用参数或返回值;另一个可传递 Hash,它将设置独属于参数或返回值的选项。例如,我希望只对参数作校验,而返回值不作校验,可如下设配置:
property :name, param: { required: true }
再比如,我对参数不设置默认值,而返回渲染的时候提供默认值(这个例子比较少见):
property :age, render: { default: 18 }
上一节讲的是字段如何区分参数或返回值的情况。这是一个方面,另一方面是如何控制在不同接口下的字段返回。
例如,我们定义列表接口时不需要返回详情字段,一来是列表页面用不到,另一来是详情内容会导致返回实体过大而造成网络拥塞。这时,scope
选项能够起到作用了。我们定义一个实体 ArticleEntity
:
class ArticleEntity < Meta::Entity
property :title
property :content, render: { scope: 'full' }
end
小提示:
scope
选项放在render
下定义,因为参数获取不需要区分场景。
注意到 content
被限制了 scope 为 "full"
了,默认情况下它是不会返回的。像列表接口就可以直接渲染它:
get '/articles' do
status 200 do
expose :articles, ref: ArticleEntity
end
action do
articles = Article.all
render :articles, articles
end
end
而详情接口下需要返回 content
字段,需要明确附加一个 scope
为 "full"
:
get '/articles/:id' do
status 200 do
expose :article, ref: ArticleEntity
end
action do
article = Article.find(request.params['id'])
render :article, article, scope: "full"
end
end
如果你认为在调用 render
方法时较为繁琐,好奇为什么不在声明时定义。如果你更青睐这种方式,可以在声明时控制,方法是将实体锁住。实体被锁住后就不需要在 render
时传递任何选项了:
get '/articles/:id' do
status 200 do
expose :article, ref: ArticleEntity.lock_scope('full')
end
action do
article = Article.find(request.params['id'])
render :article, article
end
end
也许在参数声明中这种方式更有效果。因为调用参数时我们无法传递 scope
选项,锁住是唯一的途径:
post '/articles' do
params do
param :article, ref: ArticleEntity.lock_scope('on_create')
end
...
end
put '/articles/:id' do
params do
param :article, ref: ArticleEntity.lock_scope('on_update')
end
...
end
在路由层使用 scope
宏命令,为参数和返回值提供共同的 scope
定义。
get '/articles' do
title '列表获取接口'
scope 'list'
status 200 do
expose :articles, type: 'array', ref: ArticleEntity # 不需要调用 lock_scope('list')
end
end
post '/articles' do
title '创建单个资源的接口'
scope 'details'
params do
param :article, ref: ArticleEntity # 不需要调用 lock_scope('details')
end
status 200 do
expose :article, ref: ArticleEntity # 不需要调用 lock_scope('details')
end
end
此外,由于是在路由层定义,其可以在父级的 meta
块中共同定义,将会作用到所有子路由:
namespace '/articles/:id' do
meta do
scope 'details'
end
get do
title '获取文章详情'
status 200 do
expose :article, ref: ArticleEntity # 不需要调用 lock_scope('details')
end
end
put '/articles' do
title '更新文章'
params do
param :article, ref: ArticleEntity # 不需要调用 lock_scope('details')
end
status 200 do
expose :article, ref: ArticleEntity # 不需要调用 lock_scope('details')
end
end
end
最后一点,框架为路由的方法创建特殊的 scope,以 $
符号开头。也就是说,前面的 on_create
、on_update
例子可以改写为:
post '/articles' do
params do
param :article, ref: ArticleEntity
end
end
put '/articles/:id' do
params do
param :article, ref: ArticleEntity
end
end
class ArticleEntity < Meta::Entity
property :on_create, scope: '$post' # 不用 scope: 'on_create'
property :on_update, scope: '$put' # 不用 scope: 'on_update'
end
为 HTTP 方法自动生成的 scope 包括 $get
、$post
、$put
、$patch
、$delete
.
假设现有如下实体:
class UserEntity < Meta::Entity
property :first_name
property :last_name
end
现在我们想要加一个 full_name
字段,它是 first_name
和 last_name
加起来的结果。这时我们可以使用 value
选项自己将结果计算下来:
class UserEntity < Meta::Entity
property :first_name
property :last_name
property :full_name, param: false, value: lambda do |user|
"#{user['first_name']} #{user['last_name']}"
end
end
小提示: 设置
param
为false
,因为参数获取时没有这个字段。
value
传递的块可以访问到执行环境,以下是一个示例:
status 200 do
expose :is_admin, value: lambda { @user.admin? }
end
action do
@user = get_user
end
首先,我们定义实体:
class ArticleEntity < Meta::Entity
property :image,
type: 'string',
description: '客户端传递 Base64 格式的数据'
end
然后我们定义路由参数:
params do
param :article, ref: ArticleEntity
end
由于客户端传递的参数是 Base64 格式的,我们需要在执行环境中将其转化为原本格式:
action do
article_params = {
**params[:article],
image: decode_base64(params[:article][:image])
}
end
但每个接口下都做这样的转化工作挺是繁琐,框架提供了 convert
选项,它用于将值转化为预期的格式:
class ArticleEntity < Meta::Entity
property :image,
type: 'string',
description: '客户端传递 Base64 格式的数据',
param: {
convert: lambda { |value| decode_base64(value) }
}
end
小提示: 注意只应当在参数过程中做格式转换,渲染过程中做同样的转换将会出错。
这样执行环境中就不需要手动进行格式转换了:
action do
article_params = params[:article]
end
定义属性时可定义多态类型,dynamic_ref
选项可接受一个块,它根据值来返回指定的类型:
property :target, dynamic_ref: ->(value) {
# 根据 value.target_type 值返回实体类型
# 例如,value.target_type == 'UserEntity',将返回 UserEntity 类
value.target_type.constantize
}
或者接受一个 Hash,这时可提供 one_of
选项为文档生成提供加成:
property :animal, dynamic_ref: {
one_of: [CatEntity, DogEntity, PigEntity],
resolve: ->(value) { value.animal_type.constantize }
}
if:
选项可以动态地生成字段。if:
接受一个块,其可以访问当前环境。以下代码通过当前用户是否是管理员身份来确定是否包含 published
字段。
property :published, if: ->{ current_user.admin? }
before:
选项用于前置转换值,after:
选项用作后置转换值,例如:
class UserEntity < Meta::Entity
property :address, param: { after: ->(value) { Address.new(value) }}
property :birthday, format: /\d\d\d\d-\d\d-\d\d/, render: { before: ->(birthday) { birthday.formated('%yyyy-%mm-%dd') }}
end
address
参数加入了一个后置转换值的方法,其在最后将参数值转化为 Address
对象。birthday
在实体渲染时,加入了一个前置转换值的方法,它将 DateTime
类型转化为特定格式的字符串。
before:
与value:
的区别:
value:
块比before:
块先执行;value:
块执行时,从父对象获取的值会被忽略,将以value:
块返回的结果作为后续处理用到的值;before:
块仅作转化值的用处,它接受一个value
参数,并返回转化后的结果。
with_common_options
方法用于将一组选项应用到多个字段上。例如,以下代码:
class UserEntity < Meta::Entity
property :name, type: 'string', required: true, scope: 'details'
property :age, type: 'integer', required: true, scope: 'details'
end
可以优化为:
class UserEntity < Meta::Entity
with_common_options required: true, scope: 'details' do
property :name, type: 'string'
property :age, type: 'integer'
end
end
class UserEntity < Meta::Entity
scope 'details', required: true do
property :name, type: 'string'
property :age, type: 'integer'
end
end
class UserEntity < Meta::Entity
params required: true do
property :name, type: 'string'
property :age, type: 'integer'
end
end
等价于:
class UserEntity < Meta::Entity
property :name, type: 'string', required: true, render: false
property :age, type: 'integer', required: true, render: false
end
class UserEntity < Meta::Entity
render required: true do
property :name, type: 'string'
property :age, type: 'integer'
end
end
等价于:
class UserEntity < Meta::Entity
property :name, type: 'string', required: true, param: false
property :age, type: 'integer', required: true, param: false
end
对于 Entity,可以将定义分配到不同的片段中去,使用时随意组合不同的片段即可。
class DemoEntity < Meta::Entity
fragment :a do
property :a
end
fragment :b do
property :b
end
fragment :c do
property :c
end
end
对于外面的属性定义
property :foo, ref: DemoEntity[:a, :b]
等价于引用了这样的实体
class DemoEntity < Meta::Entity
property :a
property :b
end
应用模块提供一个 to_swagger_doc
方法生成 Open API 规格文档,该文档可被 Swagger UI 或基于 Swagger UI 的引擎渲染。
class DemoApp < Meta::Application
end
# 生成 JSON 格式的规格文档
DemoApp.to_swagger_doc(
info: {
title: 'Web API 示例项目',
version: 'current'
},
servers: [
{ url: 'http://localhost:9292', description: 'Web API 示例项目' }
]
)
其中 info
和 servers
选项是 Open API 规格文档 中提供。
了解 Open API 规格文档。
了解 Swagger UI.
Meta.config.json_schema_user_options = {...}
Meta.config.json_schema_param_stage_user_options = {...}
Meta.config.json_schema_render_stage_user_options = {...}
示例一:关闭渲染时验证
渲染时不执行类型转换和数据验证:
Meta.config.json_schema_render_stage_user_options = {
type_conversion: false,
render_validation: false
}
示例二:默认使用 discard_missing: true
方案
Meta.config.json_schema_user_options = {
discard_missing: true
}
或仅在参数阶段使用 discard_missing: true
方案:
Meta.config.json_schema_param_stage_user_options = {
discard_missing: true
}
提示:可以传入一切
JsonSchema#filter
支持的选项,参考 JsonSchema#filter 支持的选项。
虽然推荐的方案是在实体之上包裹一个根字段,像下面这样:
params do
param :user, ref: UserEntity
end
# 接受如下格式的数据
{ "user": { "name": "Jim", "age": 18 } }
但也可以将包裹的外层字段去掉,即将 UserEntity
直接用在顶层:
params ref: UserEntity
# 接受如下格式的数据
{ "name": "Jim", "age": 18 }
这个方案同时也支持数组:
params type: 'array', ref: UserEntity
# 接受如下格式的数据
[
{ "name": "Jim", "age": 18 },
{ "name": "Jack", "age": 19 }
]
虽然更不常见,标量值也是支持的:
params type: 'string'
# 接受字符串数据
"foo"
HTTP 提供了两个方法 PUT
和 PATCH
,它们的语义差别体现在更新策略上。PUT
要求是完整更新,PATCH
要求是局部更新。
假设我们定义参数格式为:
params do
param :user do
param :name
param :age
end
end
同时我们收到客户端的参数格式为:
{
"user": {
"name": "Jim"
}
}
params
方法默认的逻辑符合完整更新:
put '/users/:id' do
action do
user = User.find(request.params['id'])
user_params = params[:user] # => { name: "Jim", age: nil }
user.update(user_params)
end
end
而 params(:discard_missing)
将符合局部更新的逻辑:
patch '/users/:id' do
action do
user = User.find(request.params['id'])
user_params = params(:discard_missing)[:user] # => { name: "Jim" }
user.update(user_params)
end
end
**小提示:**还有一种调用方式
params(:raw)
,它返回无任何转换逻辑的原生参数。它与request.params
的行为一致。
**大提示:**如果你是通过
ref:
引用一个实体定义,另一个更符合语义的方式是使用lock
方法。patch '/users/:id' do params do param :user, ref: UserEntity.lock(:discard_missing, true) end action do user = User.find(params['id']) user_params = params[:user] # 不需要传递 `:discard_missing` 符号了,同样也会返回 `{ name: "Jim" }` user.update(user_params) end end
Meta::Errors::NoMatchingRoute
只在顶层捕获有效,在内部捕获无效。
class DemoApp < Meta::Application
# 在此捕获有效
rescue_error Meta::Errors::NoMatchingRoute do |e|
response.status = 404
response.body = ["404 Not Found"]
end
namespace '/namespace' do
# 在此捕获无效
rescue_error Meta::Errors::NoMatchingRoute do |e|
response.status = 404
response.body = ["404 Not Found"]
end
end
end
我们有时候遇到这样一个需求:需要把另一个实体的字段合并到当前的实体。在其他框架里,你会发现诸如继承(inherited)、轨迹(trait)这样的术语;但是 Meta 框架没有。事实上,我们可以通过 Ruby 语言本身的表达做到这样的效果,因此对于该特性 Meta 框架目前采取谨慎加入的态度。
举一个表单控件实体和控件值实体的例子。下面是控件实体:
class ControlEntity < Meta::Entity
property :id, type: 'integer', description: '控件 ID'
property :label, type: 'string', description: '标签名称,显示在控件的左边'
property :style, type: 'string', description: '控件类型,如文本框、下拉框、单选框等'
property :required, type: boolean, description: '是否必选'
property :multiple, type: boolean, description: '是否多项值,当用在下拉框时可选中多个值'
end
另有一个控件值实体:
class ControlValueEntity < Meta::Entity
property :id, type: 'integer', description: '控件 ID'
property :label, type: 'string', description: '标签名称,显示在控件的左边'
# 包含控件的其他字段……
property :value, type: 'object', description: '控件填入的值'
end
以上显然有重复。我们可以将 ControlEntity
的代码放到一个 Proc
中,两个实体内都执行一次:
ControlEntityProc = Proc.new do
property :id, type: 'integer', description: '控件 ID'
property :label, type: 'string', description: '标签名称,显示在控件的左边'
property :style, type: 'string', description: '控件类型,如文本框、下拉框、单选框等'
property :required, type: boolean, description: '是否必选'
property :multiple, type: boolean, description: '是否多项值,当用在下拉框时可选中多个值'
end
class ControlEntity < Meta::Entity
instance_exec &ControlEntityProc
end
class ControlValueEntity < Meta::Entity
instance_exec &ControlEntityProc
property :value, type: 'object', description: '控件填入的值'
end
Meta 框架还提供了一个 use
方法,它可以代替 instance_exec
方法。没什么特别的意思,只是简化了一丢丢代码而已:
class ControlValueEntity < Meta::Entity
use ControlEntityProc
property :value, type: 'object', description: '控件填入的值'
end