Skip to content

Standalone appearance of nested routes #220

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 2, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -1,42 +1,42 @@
# This configuration was generated by `rubocop --auto-gen-config`
# on 2015-02-12 10:19:50 -0600 using RuboCop version 0.27.0.
# on 2015-02-26 15:04:26 +0100 using RuboCop version 0.27.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.

# Offense count: 8
# Offense count: 9
Metrics/AbcSize:
Max: 334
Max: 346

# Offense count: 1
# Configuration parameters: CountComments.
Metrics/ClassLength:
Max: 413
Max: 466

# Offense count: 5
# Offense count: 6
Metrics/CyclomaticComplexity:
Max: 97
Max: 99

# Offense count: 232
# Offense count: 289
# Configuration parameters: AllowURI, URISchemes.
Metrics/LineLength:
Max: 254

# Offense count: 16
# Offense count: 17
# Configuration parameters: CountComments.
Metrics/MethodLength:
Max: 361
Max: 367

# Offense count: 4
# Offense count: 5
Metrics/PerceivedComplexity:
Max: 98
Max: 101

# Offense count: 8
Style/ClassVars:
Enabled: false

# Offense count: 70
# Offense count: 76
Style/Documentation:
Enabled: false

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* [#196](https://github.com/tim-vandecasteele/grape-swagger/pull/196): If `:type` is omitted, see if it's available in `:using` - [@jhollinger](https://github.com/jhollinger).
* [#200](https://github.com/tim-vandecasteele/grape-swagger/pull/200): Treat `type: Symbol` as string form parameter - [@ypresto](https://github.com/ypresto).
* [#207](https://github.com/tim-vandecasteele/grape-swagger/pull/207): Support grape `mutually_exclusive` - [@mintuhouse](https://github.com/mintuhouse).
* [#220](https://github.com/tim-vandecasteele/grape-swagger/pull/220): Support standalone appearance of namespace routes with a custom name instead of forced nesting - [@croeck](https://github.com/croeck).

#### Fixes

Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,44 @@ You can specify a swagger nickname to use instead of the auto generated name by
desc 'Get a full list of pets', nickname: 'getAllPets'
```

## Expose nested namespace as standalone route
Use the `nested: false` property in the `swagger` option to make nested namespaces appear as standalone resources.
This option can help to structure and keep the swagger schema simple.

namespace 'store/order', desc: 'Order operations within a store', swagger: { nested: false } do
get :order_id do
...
end
end

All routes that belong to this namespace (here: the `GET /order_id`) will then be assigned to the `store_order` route instead of the `store` resource route.

It is also possible to expose a namspace within another already exposed namespace:

namespace 'store/order', desc: 'Order operations within a store', swagger: { nested: false } do
get :order_id do
...
end
namespace 'actions', desc: 'Order actions' do, nested: false
get 'evaluate' do
...
end
end
end

Here, the `GET /order_id` appears as operation of the `store_order` resource and the `GET /evaluate` as operation of the `store_orders_actions` route.

### With a custom name
Auto generated names for the standalone version of complex nested resource do not have a nice look.
You can set a custom name with the `name` property inside the `swagger` option, but only if the namespace gets exposed as standalone route.
The name should not contain whitespaces or any other special characters due to further issues within swagger-ui.

namespace 'store/order', desc: 'Order operations within a store', swagger: { nested: false, name: 'Store-orders' } do
get :order_id do
...
end
end

## Grape Entities

Add the [grape-entity](https://github.com/agileanimal/grape-entity) gem to our Gemfile.
Expand Down
101 changes: 90 additions & 11 deletions lib/grape-swagger.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
module Grape
class API
class << self
attr_reader :combined_routes, :combined_namespaces
attr_reader :combined_routes, :combined_namespaces, :combined_namespace_routes, :combined_namespace_identifiers

def add_swagger_documentation(options = {})
documentation_class = create_documentation_class
Expand All @@ -33,6 +33,13 @@ def add_swagger_documentation(options = {})

@combined_namespaces = {}
combine_namespaces(self)

@combined_namespace_routes = {}
@combined_namespace_identifiers = {}
combine_namespace_routes(@combined_namespaces)

exclusive_route_keys = @combined_routes.keys - @combined_namespaces.keys
exclusive_route_keys.each { |key| @combined_namespace_routes[key] = @combined_routes[key] }
documentation_class
end

Expand All @@ -45,12 +52,77 @@ def combine_namespaces(app)
else
endpoint.settings.stack.last[:namespace]
end
@combined_namespaces[ns.space] = ns if ns
# use the full namespace here (not the latest level only)
# and strip leading slash
@combined_namespaces[endpoint.namespace.sub(/^\//, '')] = ns if ns

combine_namespaces(endpoint.options[:app]) if endpoint.options[:app]
end
end

def combine_namespace_routes(namespaces)
# iterate over each single namespace
namespaces.each do |name, namespace|
# get the parent route for the namespace
parent_route_name = name.match(%r{^/?([^/]*).*$})[1]
parent_route = @combined_routes[parent_route_name]
# fetch all routes that are within the current namespace
namespace_routes = parent_route.collect do |route|
route if (route.route_path.start_with?("/#{name}") || route.route_path.start_with?("/:version/#{name}")) &&
(route.instance_variable_get(:@options)[:namespace] == "/#{name}" || route.instance_variable_get(:@options)[:namespace] == "/:version/#{name}")
end.compact

if namespace.options.key?(:swagger) && namespace.options[:swagger][:nested] == false
# Namespace shall appear as standalone resource, use specified name or use normalized path as name
if namespace.options[:swagger].key?(:name)
identifier = namespace.options[:swagger][:name].gsub(/ /, '-')
else
identifier = name.gsub(/_/, '-').gsub(/\//, '_')
end
@combined_namespace_identifiers[identifier] = name
@combined_namespace_routes[identifier] = namespace_routes

# get all nested namespaces below the current namespace
sub_namespaces = standalone_sub_namespaces(name, namespaces)
# convert namespace to route names
sub_ns_paths = sub_namespaces.collect { |ns_name, _| "/#{ns_name}" }
sub_ns_paths_versioned = sub_namespaces.collect { |ns_name, _| "/:version/#{ns_name}" }
# get the actual route definitions for the namespace path names
sub_routes = parent_route.collect do |route|
route if sub_ns_paths.include?(route.instance_variable_get(:@options)[:namespace]) || sub_ns_paths_versioned.include?(route.instance_variable_get(:@options)[:namespace])
end.compact
# add all determined routes of the sub namespaces to standalone resource
@combined_namespace_routes[identifier].push(*sub_routes)
else
# default case when not explicitly specified or nested == true
standalone_namespaces = namespaces.reject { |_, ns| !ns.options.key?(:swagger) || !ns.options[:swagger].key?(:nested) || ns.options[:swagger][:nested] != false }
parent_standalone_namespaces = standalone_namespaces.reject { |ns_name, _| !name.start_with?(ns_name) }
# add only to the main route if the namespace is not within any other namespace appearing as standalone resource
if parent_standalone_namespaces.empty?
# default option, append namespace methods to parent route
@combined_namespace_routes[parent_route_name] = [] unless @combined_namespace_routes.key?(parent_route_name)
@combined_namespace_routes[parent_route_name].push(*namespace_routes)
end
end
end
end

def standalone_sub_namespaces(name, namespaces)
# assign all nested namespace routes to this resource, too
# (unless they are assigned to another standalone namespace themselves)
sub_namespaces = {}
# fetch all namespaces that are children of the current namespace
namespaces.each { |ns_name, ns| sub_namespaces[ns_name] = ns if ns_name.start_with?(name) && ns_name != name }
# remove the sub namespaces if they are assigned to another standalone namespace themselves
sub_namespaces.each do |sub_name, sub_ns|
# skip if sub_ns is standalone, too
next unless sub_ns.options.key?(:swagger) && sub_ns.options[:swagger][:nested] == false
# remove all namespaces that are nested below this standalone sub_ns
sub_namespaces.each { |sub_sub_name, _| sub_namespaces.delete(sub_sub_name) if sub_sub_name.start_with?(sub_name) }
end
sub_namespaces
end

def get_non_nested_params(params)
# Duplicate the params as we are going to modify them
dup_params = params.each_with_object(Hash.new) do |(param, value), dparams|
Expand Down Expand Up @@ -394,20 +466,21 @@ def setup(options)
header['Access-Control-Allow-Origin'] = '*'
header['Access-Control-Request-Method'] = '*'

routes = target_class.combined_routes
namespaces = target_class.combined_namespaces
namespace_routes = target_class.combined_namespace_routes

if @@hide_documentation_path
routes.reject! { |route, _value| "/#{route}/".index(@@documentation_class.parse_path(@@mount_path, nil) << '/') == 0 }
namespace_routes.reject! { |route, _value| "/#{route}/".index(@@documentation_class.parse_path(@@mount_path, nil) << '/') == 0 }
end

routes_array = routes.keys.map do |local_route|
next if routes[local_route].map(&:route_hidden).all? { |value| value.respond_to?(:call) ? value.call : value }
namespace_routes_array = namespace_routes.keys.map do |local_route|
next if namespace_routes[local_route].map(&:route_hidden).all? { |value| value.respond_to?(:call) ? value.call : value }

url_format = '.{format}' unless @@hide_format

description = namespaces[local_route] && namespaces[local_route].options[:desc]
description ||= "Operations about #{local_route.pluralize}"
original_namespace_name = target_class.combined_namespace_identifiers.key?(local_route) ? target_class.combined_namespace_identifiers[local_route] : local_route
description = namespaces[original_namespace_name] && namespaces[original_namespace_name].options[:desc]
description ||= "Operations about #{original_namespace_name.pluralize}"

{
path: "/#{local_route}#{url_format}",
Expand All @@ -419,7 +492,7 @@ def setup(options)
apiVersion: api_version,
swaggerVersion: '1.2',
produces: @@documentation_class.content_types_for(target_class),
apis: routes_array,
apis: namespace_routes_array,
info: @@documentation_class.parse_info(extra_info)
}

Expand All @@ -441,7 +514,7 @@ def setup(options)
header['Access-Control-Request-Method'] = '*'

models = []
routes = target_class.combined_routes[params[:name]]
routes = target_class.combined_namespace_routes[params[:name]]
error!('Not Found', 404) unless routes

visible_ops = routes.reject do |route|
Expand Down Expand Up @@ -496,10 +569,16 @@ def setup(options)
}
end

# use custom resource naming if available
if target_class.combined_namespace_identifiers.key? params[:name]
resource_path = target_class.combined_namespace_identifiers[params[:name]]
else
resource_path = params[:name]
end
api_description = {
apiVersion: api_version,
swaggerVersion: '1.2',
resourcePath: "/#{params[:name]}",
resourcePath: "/#{resource_path}",
produces: @@documentation_class.content_types_for(target_class),
apis: apis
}
Expand Down
Loading