Module: Apiculture

Defined in:
lib/apiculture.rb,
lib/apiculture/version.rb

Overview

Allows brief definitions of APIs for documentation and parameter checks

Defined Under Namespace

Modules: SinatraInstanceMethods Classes: Action, ActionDefinition, AppDocumentation, ConflictingParameter, MarkdownSegment, MethodDocumentation, MissingParameter, Parameter, ParameterTypeMismatch, PossibleResponse, ReservedParameter, RouteParameter, RouteParameterNotInPath, TimestampPromise

Constant Summary collapse

IDENTITY_PROC =
->(arg) { arg }
AC_APPLY_TYPECAST_PROC =
->(cast_proc_or_method, v) {
  cast_proc_or_method.is_a?(Symbol) ? v.public_send(cast_proc_or_method) : cast_proc_or_method.call(v)
}
AC_CHECK_PRESENCE_PROC =
->(name_as_string, params) {
  params.has_key?(name_as_string) or raise MissingParameter.new(name_as_string)
}
AC_CHECK_TYPE_PROC =
->(param, value) {
  param.matchable === value or raise ParameterTypeMismatch.new(param, value.class)
}
DefinitionError =
Class.new(StandardError)
ValidationError =
Class.new(StandardError)
VERSION =
'0.0.19'

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.extended(in_class) ⇒ Object



10
11
12
13
# File 'lib/apiculture.rb', line 10

def self.extended(in_class)
  in_class.send(:include, SinatraInstanceMethods)
  super
end

Instance Method Details

#api_documentationObject

Returns an AppDocumentation object for all actions defined so far.

MyApi.api_documentation.to_markdown #=> "..."
MyApi.api_documentation.to_html #=> "..."


202
203
204
205
# File 'lib/apiculture.rb', line 202

def api_documentation
  require_relative 'apiculture/app_documentation'
  AppDocumentation.new(self, @apiculture_mounted_at.to_s, @apiculture_actions_and_docs || [])
end

#api_method(http_verb, path, options = {}, &blk) ⇒ Object

Define an API method. Under the hood will call the related methods in Sinatra to define the route.



209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/apiculture.rb', line 209

def api_method(http_verb, path, options={}, &blk)
  action_def = (@apiculture_action_definition || ActionDefinition.new)
  action_def.http_verb = http_verb
  action_def.path = path
  
  # Ensure no reserved Sinatra parameters are used
  all_parameter_names = action_def.all_parameter_names_as_strings
  %w( splat captures ).each do | reserved_param |
    if all_parameter_names.include?(reserved_param)
      raise ReservedParameter.new(":#{reserved_param} is a reserved magic parameter name in Sinatra")
    end
  end
  
  # Ensure no conflations between route/req params
  seen_params = {}
  all_parameter_names.each do |e| 
    if seen_params[e]
      raise ConflictingParameter.new(":#{e} mentioned twice as a possible parameter. Note that URL" + 
        " parameters and request parameters share a namespace.")
    else
      seen_params[e] = true
    end
  end
  
  # Ensure the path has the route parameters that were predeclared
  action_def.route_parameters.map(&:name).each do | route_parameter_key |
    unless path.include?(':%s' % route_parameter_key)
      raise RouteParameterNotInPath.new("Parameter :#{route_parameter_key} not present in path #{path.inspect}")
    end
  end
  
  # TODO: ensure all route parameters are documented
  
  # Pick out all the defined parameters and set up a block that can validate them
  # when the action is called. With that, set up the actual Sinatra method that will
  # respond to the request. We take care to preserve all the params that have NOT been documented
  # using Apiculture but _were_ in fact specified in the actual path.
  route_parameter_names = path.scan(/:([^:\/]+)/).flatten.map(&:to_sym)
  parametric_checker_proc = parametric_validator_proc_from(action_def.parameters + action_def.route_parameters, route_parameter_names)
  public_send(http_verb, path, options) do |*matched_sinatra_route_params|
    # Extract all the parameter names from the route path as given to the method
    route_parameters = Hash[route_parameter_names.zip(matched_sinatra_route_params)]

    # Apply route parameter checks, but only to params that were defined in the Apiculture action descriptor.
    # All the other params have to go via bypass.
    checked_route_parameters = action_def.route_parameters.select {|par| route_parameter_names.include?(par.name) }
    checked_route_parameters.each do |route_param|
      # Apply the type cast and save it (since using our override we can mutate the params)
      value_from_route_params = route_parameters.fetch(route_param.name)
      value_after_type_cast = AC_APPLY_TYPECAST_PROC.call(route_param.cast_proc_or_method, value_from_route_params)
      # Ensure the typecast value adheres to the enforced Ruby type
      AC_CHECK_TYPE_PROC.call(route_param, value_after_type_cast)
      # ..and overwrite it in the route parameters hash
      route_parameters[route_param.name] = value_after_type_cast
    end
    # Execute parametric checks on all the OTHER params (forms etc.)
    instance_exec(&parametric_checker_proc)
    # Execute the original action via instance_exec, passing along the route args
    instance_exec(*route_parameters.values, &blk)
  end
  
  # Reset for the subsequent action definition
  @apiculture_action_definition = ActionDefinition.new
  # and store the just defined action for future use
  apiculture_stack << action_def
end

#apiculture_stackObject



276
277
278
279
# File 'lib/apiculture.rb', line 276

def apiculture_stack
  @apiculture_actions_and_docs ||= []
  @apiculture_actions_and_docs
end

#desc(action_description) ⇒ Object

Describe the API method that is going to be defined



96
97
98
99
# File 'lib/apiculture.rb', line 96

def desc(action_description)
  @apiculture_action_definition ||= ActionDefinition.new
  @apiculture_action_definition.description = action_description.to_s
end

#documentation_build_time!Object

Inserts the generation timestamp into the documentation at this point. The timestamp will be not very precise (to the minute) and in UTC time



63
64
65
# File 'lib/apiculture.rb', line 63

def documentation_build_time!
  apiculture_stack << Apiculture::TimestampPromise
end

#markdown_file(path_to_markdown) ⇒ Object

Inserts the contents of the file at path into the documentation, using markdown_string. For instance, if used after an API method declaration, it will insert the header between the API methods in the doc.

markdown_file "SECURITY_CONSIDERATIONS.md"
api_method :get, '/bar/thing' do
  #...
end


90
91
92
93
# File 'lib/apiculture.rb', line 90

def markdown_file(path_to_markdown)
  md = File.read(path_to_markdown).encode(Encoding::UTF_8)
  markdown_string(md)
end

#markdown_string(str) ⇒ Object

Inserts a literal Markdown string into the documentation at this point. For instance, if used after an API method declaration, it will insert the header between the API methods in the doc.

api_method :get, '/foo/bar' do
  #...
end
markdown_string "# Subsequent methods do thing to Bars"
api_method :get, '/bar/thing' do
  #...
end


78
79
80
# File 'lib/apiculture.rb', line 78

def markdown_string(str)
  apiculture_stack << MarkdownSegment.new(str)
end

#mounted_at(path) ⇒ Object

Indicates where this API will be mounted. This is only used for the generated documentation. In general, this should match the SCRIPT_NAME of the Sinatra application when it will be called. For example, if you use this in your config.ru:

map('/api/v3') { run MyApi }

then it is handy to set that with mounted_at as well so that the API documentation references the mountpoint:

mounted_at '/api/v3'

Again: this does not change the way requests are handled in any way, it just alters the documentation output.



57
58
59
# File 'lib/apiculture.rb', line 57

def mounted_at(path)
  @apiculture_mounted_at = path.to_s.gsub(/\/$/, '')
end

#param(name, description, matchable, cast: IDENTITY_PROC) ⇒ Object

Add an optional parameter for the API call



102
103
104
105
# File 'lib/apiculture.rb', line 102

def param(name, description, matchable, cast: IDENTITY_PROC)
  @apiculture_action_definition ||= ActionDefinition.new
  @apiculture_action_definition.parameters << Parameter.new(name, description, required=false, matchable, cast)
end

#parametric_validator_proc_from(parametric_validators, implicitly_defined_route_parameter_names) ⇒ Object

Returns a Proc that calls the strong parameters to check the presence/types



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
# File 'lib/apiculture.rb', line 153

def parametric_validator_proc_from(parametric_validators, implicitly_defined_route_parameter_names)
  required_params = parametric_validators.select{|e| e.required }
  # Return a lambda that will be called with the Sinatra params
  parametric_validation_blk = ->{
    # Within this block +params+ is the Sinatra's instance params
    # Ensure the required parameters are present first, before applying casts/validations etc.
    required_params.each { |param| AC_CHECK_PRESENCE_PROC.call(param.name_as_string, params) }
    parametric_validators.each do |param|
      param_name = param.name_as_string
      next unless params.has_key?(param_name) # this is checked via required_params
      
      # Apply the type cast and save it (since using our override we can mutate the params)
      value_after_type_cast = AC_APPLY_TYPECAST_PROC.call(param.cast_proc_or_method, params[param_name])
      params[param_name] = value_after_type_cast
      
      # Ensure the typecast value adheres to the enforced Ruby type
      AC_CHECK_TYPE_PROC.call(param, params[param_name])
    end
    
    # The following only applies if the app does not use strong_parameters - 
    # this makes use of parameter mutability again to kill the parameters that are not permitted
    # or mentioned in the API specification. We need to keep the params which are specified in the
    # route but not documented via Apiculture though
    unexpected_parameters = Set.new(params.keys.map(&:to_s)) -
      Set.new(parametric_validators.map(&:name).map(&:to_s)) -
      Set.new(implicitly_defined_route_parameter_names.map(&:to_s))
    
    unexpected_parameters.each do | parameter_to_discard |
      # TODO: raise or record a warning
      if env['rack.logger'].respond_to?(:warn)
        env['rack.logger'].warn "Discarding disallowed parameter #{parameter_to_discard.inspect}"
      end
      params.delete(parameter_to_discard)
    end
  }
end

#required_param(name, description, matchable, cast: IDENTITY_PROC) ⇒ Object

Add a requred parameter for the API call



108
109
110
111
# File 'lib/apiculture.rb', line 108

def required_param(name, description, matchable, cast: IDENTITY_PROC)
  @apiculture_action_definition ||= ActionDefinition.new
  @apiculture_action_definition.parameters << Parameter.new(name, description, required=true, matchable, cast)
end

#responds_with(http_status, description, example_jsonable_object = nil) ⇒ Object

Add a possible response, specifying the code and the JSON Response by example. Multiple response packages can be specified.



124
125
126
127
# File 'lib/apiculture.rb', line 124

def responds_with(http_status, description, example_jsonable_object = nil)
  @apiculture_action_definition ||= ActionDefinition.new
  @apiculture_action_definition.responses << PossibleResponse.new(http_status, description, example_jsonable_object)
end

#route_param(name, description, matchable = String, cast: IDENTITY_PROC) ⇒ Object

Describe a parameter that has to be included in the URL of the API call. Route parameters are always required, and all the parameters specified using route_param should also be included in the path given for the route definition



117
118
119
120
# File 'lib/apiculture.rb', line 117

def route_param(name, description, matchable = String, cast: IDENTITY_PROC)
  @apiculture_action_definition ||= ActionDefinition.new
  @apiculture_action_definition.route_parameters << RouteParameter.new(name, description, required=false, matchable, cast)
end

#serve_api_documentation_at(url) ⇒ Object

Serve the documentation for the API at the given URL



191
192
193
194
195
196
# File 'lib/apiculture.rb', line 191

def serve_api_documentation_at(url)
  get(url) do
    content_type :html
    self.class.api_documentation.to_html
  end
end