Skip to content

Commit 2a14791

Browse files
elasti-roniNecas
authored andcommitted
Response description support, especially useful for swagger
1 parent 6ab501a commit 2a14791

21 files changed

+2321
-24
lines changed

PROPOSAL_FOR_RESPONSE_DESCRIPTIONS.md

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
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

Comments
 (0)