|
| 1 | +# Proposal for supporting response descriptions in Apipie |
| 2 | + |
| 3 | +## Rationale |
| 4 | + |
| 5 | +Swagger allows API authors to describe the structure of objects returned by REST API calls. |
| 6 | +Client authors and code generators can use such descriptions for various purposes, such as verification, |
| 7 | +autocompletion, and so forth. |
| 8 | + |
| 9 | +The current Apipie DSL allows API authors to indicate returned error codes (using the `error` keyword), |
| 10 | +but does not support descriptions of returned data objects. As such, swagger files |
| 11 | +generated from the DSL do not include those, and are somewhat limited in their value. |
| 12 | + |
| 13 | +This document proposes a minimalistic approach to extending the Apipie DSL to allow description of response |
| 14 | +objects, and including those descriptions in generated swagger files. |
| 15 | + |
| 16 | +## Design Objectives |
| 17 | + |
| 18 | +* Full backward compatibility with the existing DSL |
| 19 | +* Minimal implementation effort |
| 20 | +* Enough expressiveness to support common use cases |
| 21 | +* Optional integration of the DSL with advanced JSON generators (such as Grape-Entity) |
| 22 | +* Allowing developers to easily verify that actual responses match the response declarations |
| 23 | + |
| 24 | +## Approach |
| 25 | + |
| 26 | +#### Add a `returns` keyword to the DSL, based on the existing `error` keyword |
| 27 | + |
| 28 | +Currently, returned error codes are indicated using the `error` keyword, for example: |
| 29 | +```ruby |
| 30 | +api :GET, "/users/:id", "Show user profile" |
| 31 | +error :code => 401, :desc => "Unauthorized" |
| 32 | +``` |
| 33 | + |
| 34 | +The proposed approach is to add a `returns` keyword, that has the following syntax: |
| 35 | +```ruby |
| 36 | +returns <type-identifier> [, :code => <number>] [, :desc => <response-description>] |
| 37 | +``` |
| 38 | + |
| 39 | +For example: |
| 40 | +```ruby |
| 41 | +api :GET, "/users/:id", "Show user profile" |
| 42 | +error :code => 401, :desc => "Unauthorized" |
| 43 | +returns :SomeTypeIdentifier # :code is not specified, so it is assumed to be 200 |
| 44 | +``` |
| 45 | + |
| 46 | + |
| 47 | +#### Leverage `param_group` for response object description |
| 48 | + |
| 49 | +Apipie currently has a mechanism for describing complex objects using the `param_group` keyword. |
| 50 | +It seems reasonable to leverage this mechanism as the basis of the response object description mechanism, |
| 51 | +so that the `<type-identifier>` in the `returns` keyword will be the name of a param_group. |
| 52 | + |
| 53 | +For example: |
| 54 | +```ruby |
| 55 | + def_param_group :user do |
| 56 | + param :user, Hash, :desc => "User info", :required => true, :action_aware => true do |
| 57 | + param_group :credentials |
| 58 | + param :membership, ["standard","premium"], :desc => "User membership", :allow_nil => false |
| 59 | + end |
| 60 | + end |
| 61 | + |
| 62 | + api :GET, "/users/:id", "Get user record" |
| 63 | + returns :user, "the requested record" |
| 64 | + error :code => 404, :desc => "no user with the specified id" |
| 65 | +``` |
| 66 | + |
| 67 | +Implementation of this DSL extension would involve - as part of the implementation of the `returns` keyword - |
| 68 | +the generation of a Apipie::ParamDescription object that has a Hash validator pointing to the param_group block. |
| 69 | + |
| 70 | +#### Extend action-aware functionality to include 'response-only' parameters |
| 71 | + |
| 72 | +In CRUD operations, it is common for `param_group` input definitions to be very similar to the |
| 73 | +output of the API, with the exception of a very small number of fields (such as the `:id` field |
| 74 | +which usually appears in the response, but is not described in the `param_group` because it is passed as a |
| 75 | +path parameter). |
| 76 | + |
| 77 | +To allow reuse of the `param_group`, it would be useful to its definition to describe parameters that are not passed |
| 78 | +in the request but are returned in the response. This would be implementing by extending the DSL to |
| 79 | +support a `:only_in => :response` option on `param` definitions. Similarly, params could be defined to be |
| 80 | +`:only_in => :request` to indicate that they will not be included in the response. |
| 81 | + |
| 82 | +For example: |
| 83 | +```ruby |
| 84 | + # in the following group, the :id param is ignored in requests, but included in responses |
| 85 | + def_param_group :user do |
| 86 | + param :user, Hash, :desc => "User info", :required => true, :action_aware => true do |
| 87 | + param :id, Integer, :only_in => :response |
| 88 | + param :requested_id, Integer, :only_in => :request |
| 89 | + param_group :credentials |
| 90 | + param :membership, ["standard","premium"], :desc => "User membership", :allow_nil => false |
| 91 | + end |
| 92 | + end |
| 93 | + |
| 94 | + api :GET, "/users/:id", "Get user record" |
| 95 | + returns :user, :desc => "the requested record" # includes the :id field, because this is a response |
| 96 | + error :code => 404, :desc => "no user with the specified id" |
| 97 | +``` |
| 98 | + |
| 99 | + |
| 100 | +#### Support `:array_of => <param_group-name>` in the `returns` keyword |
| 101 | + |
| 102 | +Very often, a REST API call returns an array of some previously-defined object |
| 103 | +(the most common example an `index` operation that returns an array of the same entity returned by a `show` request), |
| 104 | +and it would be tedious to have to define a separate `param_group` for each one. |
| 105 | + |
| 106 | +For added convenience, the `returns` keyword will also support an `:array_of =>` construct |
| 107 | +to specify that an API call returns an array of some object type. |
| 108 | + |
| 109 | +For example: |
| 110 | +```ruby |
| 111 | + api :GET, "/users", "Get all user records" |
| 112 | + returns :array_of => :user, :desc => "the requested user records" |
| 113 | + |
| 114 | + api :GET, "/user/:id", "Get a single user record" |
| 115 | + returns :user, :desc => "the requested user record" |
| 116 | +``` |
| 117 | + |
| 118 | +#### Integration with advanced JSON generators using an [adapter](https://en.wikipedia.org/wiki/Adapter_pattern) to `param_group` |
| 119 | + |
| 120 | +While it makes sense for the sake of simplicity to leverage the `param_group` construct to describe |
| 121 | +returned objects, it is likely that many developers will prefer to unify the |
| 122 | +description of the response with the actual generation of the JSON. |
| 123 | + |
| 124 | +Some JSON-generation libraries, such as [Grape-Entity](https://github.com/ruby-grape/grape-entity), |
| 125 | +provide a declarative interface for describing an object model, allowing both runtime |
| 126 | +generation of the response, as well as the ability to traverse the description to auto-generate |
| 127 | +documentation. |
| 128 | + |
| 129 | +Such libraries could be integrated with Apipie using adapters that wrap the library-specific |
| 130 | +object description and expose an API that includes a `params_ordered` method that behaves in a |
| 131 | +similar manner to [`Apipie::HashValidator.params_ordered`](https://github.com/Apipie/apipie-rails/blob/cfb42198bc39b5b30d953ba5a8b523bafdb4f897/lib/apipie/validator.rb#L315). |
| 132 | +Such an adapter would make it possible to pass an externally-defined entity to the `returns` keyword |
| 133 | +as if it were a `param_group`. |
| 134 | + |
| 135 | +Such adapters can be created easily by having a class respond to `#describe_own_properties` |
| 136 | +with an array of property description objects. When such a class is specified as the |
| 137 | +parameter to a `returns` declaration, Apipie would query the class for its properties |
| 138 | +by calling `<Class>#describe_own_properties`. |
| 139 | + |
| 140 | +For example: |
| 141 | +```ruby |
| 142 | +# here is a class that can describe itself to Apipie |
| 143 | +class Animal |
| 144 | + def self.describe_own_properties |
| 145 | + [ |
| 146 | + Apipie::prop(:id, Integer, {:description => 'Name of pet', :required => false}), |
| 147 | + Apipie::prop(:animal_type, 'string', {:description => 'Type of pet', :values => ["dog", "cat", "iguana", "kangaroo"]}), |
| 148 | + Apipie::additional_properties(false) |
| 149 | + ] |
| 150 | + end |
| 151 | + |
| 152 | + attr_accessor :id |
| 153 | + attr_accessor :animal_type |
| 154 | +end |
| 155 | + |
| 156 | +# Here is an API defined as returning Animal objects. |
| 157 | +# Apipie creates an internal adapter by querying Animal#describe_own_properties |
| 158 | +api :GET, "/animals", "Get all records" |
| 159 | +returns :array_of => Animal, :desc => "the requested records" |
| 160 | +``` |
| 161 | + |
| 162 | +The `#describe_own_properties` mechanism can also be used with reflection so that a |
| 163 | +class would query its own properties and populate the response to `#describe_own_properties` |
| 164 | +automatically. See [this gist](https://gist.github.com/elasti-ron/ac145b2c85547487ca33e5216a69f527) |
| 165 | +for an example of how Grape::Entity classes can automatically describe itself to Apipie |
| 166 | + |
| 167 | +#### Response validation |
| 168 | + |
| 169 | +The swagger definitions created by Apipie can be used to auto-generate clients that access the |
| 170 | +described APIs. Those clients will break if the responses returned from the API do not match |
| 171 | +the declarations. As such, it is very important to include unit tests that validate the actual |
| 172 | +responses against the swagger definitions. |
| 173 | + |
| 174 | +The ~~proposed~~ implemented mechanism provides two ways to include such validations in RSpec unit tests: |
| 175 | +manual (using an RSpec matcher) and automated (by injecting a test into the http operations 'get', 'post', |
| 176 | +raising an error if there is no match). |
| 177 | + |
| 178 | +Example of the manual mechanism: |
| 179 | + |
| 180 | +```ruby |
| 181 | +require 'apipie/rspec/response_validation_helper' |
| 182 | + |
| 183 | +RSpec.describe MyController, :type => :controller, :show_in_doc => true do |
| 184 | + |
| 185 | +describe "GET stuff with response validation" do |
| 186 | + render_views # this makes sure the 'get' operation will actually |
| 187 | + # return the rendered view even though this is a Controller spec |
| 188 | + |
| 189 | + it "does something" do |
| 190 | + response = get :index, {format: :json} |
| 191 | + |
| 192 | + # the following expectation will fail if the returned object |
| 193 | + # does not match the 'returns' declaration in the Controller, |
| 194 | + # or if there is no 'returns' declaration for the returned |
| 195 | + # HTTP status code |
| 196 | + expect(response).to match_declared_responses |
| 197 | + end |
| 198 | +end |
| 199 | +``` |
| 200 | + |
| 201 | + |
| 202 | +Example of the automated mechanism: |
| 203 | +```ruby |
| 204 | +require 'apipie/rspec/response_validation_helper' |
| 205 | + |
| 206 | +RSpec.describe MyController, :type => :controller, :show_in_doc => true do |
| 207 | + |
| 208 | +describe "GET stuff with response validation" do |
| 209 | + render_views |
| 210 | + auto_validate_rendered_views |
| 211 | + |
| 212 | + it "does something" do |
| 213 | + get :index, {format: :json} |
| 214 | + end |
| 215 | + it "does something else" do |
| 216 | + get :another_index, {format: :json} |
| 217 | + end |
| 218 | +end |
| 219 | + |
| 220 | +describe "GET stuff without response validation" do |
| 221 | + it "does something" do |
| 222 | + get :index, {format: :json} |
| 223 | + end |
| 224 | + it "does something else" do |
| 225 | + get :another_index, {format: :json} |
| 226 | + end |
| 227 | +end |
| 228 | +``` |
| 229 | + |
| 230 | +Explanation of the implementation approach: |
| 231 | + |
| 232 | +The Apipie Swagger Generator is enhanced to allow extraction of the JSON schema of the response object |
| 233 | +for any controller#action[http-status]. When validation is required, the validator receives the |
| 234 | +actual response object (along with information about the controller, action and http status code), |
| 235 | +queries the swagger generator to get the schema, and uses the json-schema validator (gem) to validate |
| 236 | +one against the other. |
| 237 | + |
| 238 | +Note that there is a slight complication here: while supported by JSON-shema, the Swagger 2.0 |
| 239 | +specification does not support a mechanism to declare that fields in the response could be null. |
| 240 | +As such, for a response that contains `null` fields, if the exact same schema used in the swagger def |
| 241 | +is passed to the json-schema validator, the validation fails. To work around this issue, when asked |
| 242 | +to provide the schema for the purpose of response validation (i.e., not for inclusion in the swagger), |
| 243 | +the Apipie Swagger Generator creates a slightly modified schema which declares null values to be valid. |
| 244 | + |
0 commit comments