Skip to content

Latest commit

 

History

History
1921 lines (1464 loc) · 53.5 KB

教程.md

File metadata and controls

1921 lines (1464 loc) · 53.5 KB

教程

现有的 Web API 框架并不关注文档的问题,文档往往是作为插件挂载到框架上的。但是,文档和业务实现并不需要割裂开,它们在很大程度上应该是耦合在一起的。比方说,某个接口我定义了参数如此,就该自动生成一致的文档向前端告知;同样,当我提供了文档是如此后,我的接口实现就该自动地约束为这样实现。

Meta 框架天生就是将文档和实现统一起来的,并始终致力于此(如果真的有什么不一致或者不到位的地方,那只能说框架实现上尚有欠缺,并不能从思想上说本该如此)。Meta 框架与 Swagger 合作,致力于产生符合 Restful 和社区规范的文档格式。它提供了几乎完整的描述接口信息的宏命令,并且在描述接口的同时就能基本实现接口的一些约束,其中最重要的莫过于对参数和返回值的声明。

准备工作

在正式阅读本教程之前,有一些准备工作需要提前了解的。

只接受 JSON

只接受格式为 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

路由定义(route 宏)

Meta::Application 类内,第一个能做的事情就是定义路由。route 方法(以后我们称这种特定的 DSL 方法为“宏”)定义一个具体的路由(即接口):

class DemoApp < Meta::Application
  route '/', :get do
    # 块内定义路由的详细元素
  end
end

HTTP 路径和方法

route 方法接受一个路径字符串和一个 HTTP 方法,并且可接受一个块用于定义路由的详细元素(将在后面讲到)。HTTP 方法我们一共支持五种,包括 getpostputpatchdelete. 为此,我们提供了五个便捷方法用于简化 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

因为这种写法更为清晰并且视觉效果更好,教程的以后都用 getpostputpatchdelete 五个方法代替 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 等格式的路径。

通配符符号后面的单词(idpath)是参数名称,它将路由中与其匹配的部分放到参数中可访问。这里先提一下,通过 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 等格式的路径。

定义路由的元信息(meta 宏)

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

它会产生两个方面的效果:

  1. 文档方面:接口文档的参数部分会暴露出两个参数:nameage,并声明它的类型和描述信息。
  2. 业务逻辑方面:业务代码执行时,通过标准的方法获取参数时会对参数作校验。这里面它只会提取出参数的两个字段(nameage),并对它们俩的类型作校验。如果参数不符合定义,会向客户端抛出一个错误。

meta 宏一览

meta 宏内部现在只提供了以下五个方法:

post '/users' do
  meta do
    title '创建用户'
    description '接口的详细描述'
    tags ['User'] # 定义接口的 Tag,传递一个数组
    params do
      # 内部定义参数结构
    end
    status 200 do
      # 内部定义返回值结构
    end
  end
end

以上,titledescriptiontags 宏分别定义接口的标题、描述信息和标签列表。paramsstatus 宏定义接口的参数和返回值,其内部定义比较复杂,将在后面详细讲解。

meta 宏展开

meta 宏可以展开定义,亦即可以直接在 route 定义内部直接使用 meta 宏定义的语法,它是 route 定义内部提供的一种快捷方式:

post '/users' do
  title '创建用户'
  description '接口的详细描述'
  tags ['User'] # 定义接口的 Tag,传递一个数组
  params do
    # 内部定义参数结构
  end
  status 200 do
    # 内部定义返回值结构
  end
end

由于展开定义的方式写起来更加便捷,因此后面的教程示例都将采取这样的写法。

定义路由的执行逻辑(action 宏)

action 宏定义业务代码部分。将上面的 POST /users 接口的逻辑实现定义完全,大概率是以下这个样子:

post '/users' do
  # ... 定义路由的 meta 部分
  action do
    user = User.create!(params[:user])
    render :user, user
  end
end

其中,用到的 params 方法和 render 方法将在后面讲到。

层次化地定义路由(namespace 宏)

使用 namespace 宏定义嵌套路由

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 宏有如下作用:

  1. 通过组合 namespaceroute 宏来定义应用的层次结构。
  2. namespace 内可定义钩子,用于公共的运行逻辑。
  3. 可定义 namespace 级的异常拦截处理方法。
  4. namespace 定义 meta 宏,可定义公共的“元”信息。

钩子

namesapce 内提供了两种钩子:beforeafter. 它在整个 namespace 层级执行一遍。

正如名字所表达的那样,beforeaction 宏之前执行,afteraction 宏之后执行。

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 接口时,依次打印数字 132;当用户访问 PUT /foo 接口时,依次打印数字 142.

around 钩子(实验特性)

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 接口时,依次打印数字 132;当用户访问 PUT /foo 接口时,依次打印数字 142.

around 钩子现在还处于实验阶段,不建议实际开发中使用。当 around 钩子混合定义 beforeafter 钩子时,其执行的顺序比较混乱。而且,现在的 around 钩子还无法覆盖参数解析和返回值渲染的过程,这让它们的应用范围受到限制。当需要完整覆盖接口执行的全周期时,推荐使用 Rack 的中间件。最后,一定要在恰当的时机执行 next_action.execute(self),否则后续的动作将不会得到执行。 next_action.execute(self) 调用稍显繁琐,不够优雅。

所有钩子的执行顺序

如果只包含 beforeafter 钩子,则执行的顺序是:

  1. before 钩子先执行,按照定义的顺序;
  2. 接着执行 action 定义的块;
  3. 最后执行 after 钩子,按照定义的顺序。

举例(以下按照 123 的数字顺序执行):

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 钩子,则会复杂一些,但大体上是:

  1. 最先执行的是 before 钩子以及 around 钩子的前半部分,按照定义的顺序;
  2. 接着执行 action 定义的块;
  3. 然后执行 after 钩子,按照定义的顺序;
  4. 最后执行 around 钩子的后半部分,按照定义的逆序执行。

举例(以下按照 123 的数字顺序执行):

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

使用钩子的注意事项

请注意,钩子的执行顺序是严格按照以上顺序执行的,与你定义的顺序无关。请确保 beforearound 钩子优先于 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 只能在顶层被捕获

由于框架实现的特殊性,异常 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 请求时也只有顶层的异常拦截起了作用。

namespacemeta

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

当路由在执行过程中,会将块绑定到一个 Meta::Execution 实例中执行。beforeafter 等钩子,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

Meta::Execution 提供的方法。

#request

request 方法返回 Rack::Request 实例,它是 Rack 框架提供的包装类,用于简化 HTTP 操作。

#response

response 方法返回 Rack::Response 的实例,它是 Rack 框架的包装类,用于简化 HTTP 操作。

#params

不要与 .params 宏所混淆,它是 Meta::Execution 提供的实例方法,返回解析后的参数。参数的解析参考 params 宏的定义。

#render

定义实际响应体时使用 render 方法。render 方法参考 status 定义的响应体格式过滤和验证字段。

#abort_execution!

中断后续的执行。如果是在 before 块中执行这个方法,则跳过后续的 beforeactionafter 块;如果是在 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

Meta::Application 等同于 namespace 定义

要知道 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 视为路径定义为 / 的命名空间。

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 应用

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

自定义类型定义

除了基本的 stringintegernumberobjectarray 类型外,参数还支持自定义类型。

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:参数的约束之二

正如其名,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

required 可同时配置 allow_empty: trueallow_empty: false 用以是否接受空字符串或空数组:

params do
  param :title, require: { allow_empty: false } # 不接受空字符串
  param :tags, require: { allow_empty: true }   # 可接受空数组
end

format

为字符串参数配置 format 可限制参数的格式。以下是用到的 format 示例:

# 参数定义
params do
  param :date, format: /^\d{4}-\d{2}-\d{2}$/
  param :mobile, format: /^1[3456789]\d{9}$/
  param :email, format: /^$/
end

allowable

通过一个数组配置一个字段可接受的值:

# 参数定义
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 选项配置字段的描述,这个描述会在生成文档时生效。

validate:自定义校验

如果以上的校验均不够用,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

然后在 paramsstatus 内部使用 ref 引用这个实体:

params do
  param :user, ref: UserEntity
end

status 200 do
  expose :user, ref: UserEntity
end

我们通过继承 Meta::Entity 类定义了实体并到处引用,从而简化了代码。这在实践中是推荐的方案。鉴于参数、返回值、实体的定义语法是完全一致的,故而接下来我们将重点进入实体的讲解环节。希望读者清楚的是,以上参数介绍的语法在实体定义中是完全可用的;并且,接下来有关实体的语法也能完全运用到单独的参数和返回值定义块中。

**小提示:**我们在 params 中用 param 命令定义参数字段,在 status 中用 expose 命令定义返回值字段,而在实体定义中这个命令变成了 property. 这里需要阐明的是,用 paramexpose 还是 property 只是习惯的不同而已,它们的行为都是一致的并且能够混用。例如,你完全可以在 paramsstatus 中一律使用 property 命令:

params do
    property :user, ref: UserEntity
end

status 200 do
    property :user, ref: UserEntity
end

实体定义的其他介绍

paramexpose 的只会作用到同层的字段,不会作用到实体内部。

数组内部也可以引用实体,只要在字段上加上 type: "array" 即可:

params do
  param :users, type: "array", ref: UserEntity
end

接下来会涉及之前没提过的配置选项,包括 paramrenderscopevalueconvert 等。单独说明某个选项的用法显得枯燥,我接下来将以列举场景的方式说明。

如何设置某个字段只作为参数或返回值

由于实体内部既包括参数的字段,也包括返回值的字段,必然有某些字段只可作为参数或返回值。这种情况该如何做呢?我们可以配置 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 }

引申:paramrender 本质探究

paramrender 支持两种格式:其一是刚刚见过的 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 宏命令,为参数和返回值提供共同的 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_createon_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_namelast_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

小提示: 设置 paramfalse,因为参数获取时没有这个字段。

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: 的区别:

  1. value: 块比 before: 块先执行;
  2. value: 块执行时,从父对象获取的值会被忽略,将以 value: 块返回的结果作为后续处理用到的值;before: 块仅作转化值的用处,它接受一个 value 参数,并返回转化后的结果。

(实验特性)写法上的优化 with_common_options

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

scope 方法

class UserEntity < Meta::Entity
  scope 'details', required: true do
    property :name, type: 'string'
    property :age, type: 'integer'
  end
end

params 方法

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

render 方法

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 示例项目' }
  ]
)

其中 infoservers 选项是 Open API 规格文档 中提供。

了解 Open API 规格文档

了解 Swagger UI.

全局配置

定义 JsonSchema#filter 方法的 user_options

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 提供了两个方法 PUTPATCH,它们的语义差别体现在更新策略上。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

namespace 中使用 rescue_error Meta::Errors::NoMatchingRoute 无效

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