apia-open_api 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18cbf0db6400c3d1e60a769d2064e77259bc3368ccf39e8c8b4d8c3ae99125b9
4
- data.tar.gz: c29814927fee005a855592a8d7c7689decfffdb534c5cae5d7c1d4ec1a367d54
3
+ metadata.gz: 2018fe92f478024edd0b15086e2844c6f540cecccdc508d246c2ee9f705dde8d
4
+ data.tar.gz: 0dda2bc96afb05c930a42e89d50c66bfdc38630345be9772cd06244e94cb3574
5
5
  SHA512:
6
- metadata.gz: fe2929de95c1b355815cb0d18a91af8fa373cd948ebf0465067211f75b9a3c5dc4f46795572b8ff5ce136410d0ae7865e01d8ea9c5a497adad272cb1f2e7f020
7
- data.tar.gz: bfd2cd9fe9a6a2e4148c9bf95b86b698584913c791fe03def05f99d56e484c1a49551e65fdac656d95229a90fbc6a24f41f19261d0e06f406bb7adb18bef2613
6
+ metadata.gz: 19ff009aa99228b72250b56c354a191a2407fe5f4abde636de07d637e7ed4bc4ddd6ae419d38234f8e1fdf58c073895379144d97ce2030aab440ccc8a2f0c21e
7
+ data.tar.gz: 9b739acb6d8ced837e49a5e25e46956ed2847143e3a60c0cd8fb46acbe79d6b5c2b25f64e5c9e2ba5db656f076f7add0c7e54c114e51b4b0a6d41dfcf270fec3
@@ -52,6 +52,20 @@ module Apia
52
52
  schema
53
53
  end
54
54
 
55
+ def generate_array_schema(definition)
56
+ type = definition.type
57
+ schema = {
58
+ type: "array",
59
+ items: {
60
+ type: convert_type_to_open_api_data_type(type)
61
+ }
62
+ }
63
+ schema[:description] = definition.description if definition.description.present?
64
+ schema[:items][:format] = "float" if type.klass == Apia::Scalars::Decimal
65
+ schema[:items][:format] = "date" if type.klass == Apia::Scalars::Date
66
+ schema
67
+ end
68
+
55
69
  def generate_schema_ref(definition, id: nil, sibling_props: false, **schema_opts)
56
70
  id ||= generate_id_from_definition(definition.type.klass.definition)
57
71
  success = add_to_components_schemas(definition, id, **schema_opts)
@@ -69,6 +69,9 @@ module Apia
69
69
  schema: generate_scalar_schema(@argument)
70
70
  }
71
71
  param[:description] = @argument.description if @argument.description.present?
72
+
73
+ add_pagination_params(param)
74
+
72
75
  param[:required] = true if @argument.required?
73
76
  add_to_parameters(param)
74
77
  end
@@ -76,6 +79,19 @@ module Apia
76
79
 
77
80
  private
78
81
 
82
+ def add_pagination_params(param)
83
+ if param[:name] == "page"
84
+ param[:description] = "The page number to request. If not provided, the first page will be returned."
85
+ param[:schema][:default] = 1
86
+ param[:schema][:minimum] = 1
87
+ elsif param[:name] == "per_page"
88
+ param[:description] =
89
+ "The number of items to return per page. If not provided, the default value will be used."
90
+ param[:schema][:default] = @argument.default
91
+ param[:schema][:minimum] = 1
92
+ end
93
+ end
94
+
79
95
  # Complex argument sets are not supported in query params (e.g. nested objects)
80
96
  # For any LookupArgumentSet only one argument is expected to be provided.
81
97
  # However, OpenAPI does not currently support describing mutually exclusive query params.
@@ -87,19 +103,55 @@ module Apia
87
103
  next if child_arg.type.argument_set?
88
104
 
89
105
  param = {
90
- name: "#{@argument.name}[#{child_arg.name}]",
91
- in: "query",
92
- schema: generate_scalar_schema(child_arg)
106
+ in: "query"
93
107
  }
108
+
94
109
  description = []
95
110
  description << formatted_description(@argument.description) if @argument.description.present?
96
111
  description << formatted_description(child_arg.description) if child_arg.description.present?
97
- description << "All '#{@argument.name}[]' params are mutually exclusive, only one can be provided."
112
+
113
+ add_lookup_description(description)
114
+
115
+ if @argument.array
116
+ param[:name] = "#{@argument.name}[][#{child_arg.name}]"
117
+ param[:schema] = generate_array_schema(child_arg)
118
+
119
+ add_description_section(
120
+ description,
121
+ "All `#{@argument.name}[]` params should have the same amount of elements."
122
+ )
123
+
124
+ else
125
+ param[:name] = "#{@argument.name}[#{child_arg.name}]"
126
+ param[:schema] = generate_scalar_schema(child_arg)
127
+ end
128
+
98
129
  param[:description] = description.join(" ")
99
130
  add_to_parameters(param)
100
131
  end
101
132
  end
102
133
 
134
+ # Adds a section to the description of a parameter.
135
+ # If the description is not empty, a blank line is added before the section.
136
+ #
137
+ # @param description [String] The current description of the parameter.
138
+ # @param addition [String] The section to be added to the description.
139
+ # @return [String] The updated description with the added section.
140
+ def add_description_section(description, addition)
141
+ unless description.empty?
142
+ description << "\n\n"
143
+ end
144
+
145
+ description << addition
146
+ end
147
+
148
+ def add_lookup_description(description)
149
+ add_description_section(
150
+ description,
151
+ "All '#{@argument.name}[]' params are mutually exclusive, only one can be provided."
152
+ )
153
+ end
154
+
103
155
  def add_to_parameters(param)
104
156
  @route_spec[:parameters] << param
105
157
  end
@@ -36,20 +36,31 @@ module Apia
36
36
  @api_authenticator = api_authenticator
37
37
  @route_spec = {
38
38
  operationId: convert_route_to_id,
39
- tags: [name]
39
+ summary: @route.endpoint.definition.name,
40
+ description: @route.endpoint.definition.description,
41
+ tags: route.group ? get_group_tags(route.group) : [name],
42
+ security: []
40
43
  }
41
44
  end
42
45
 
43
46
  def add_to_spec
47
+ add_scopes_description
48
+ add_scopes_security
44
49
  path = @route.path
50
+
45
51
  if @route.request_method == :get
46
52
  add_parameters
47
53
  else
48
54
  add_request_body
49
55
  end
50
56
 
51
- @spec[:paths]["/#{path}"] ||= {}
52
- @spec[:paths]["/#{path}"][@route.request_method.to_s] = @route_spec
57
+ path = "/#{path}"
58
+ # Remove the `:` from the url parameters in the path
59
+ # This is because some tools based on the OpenAPI spec don't like the `:` in the path
60
+ path = path.gsub(/:([^\/]+)/, '\1')
61
+
62
+ @spec[:paths][path] ||= {}
63
+ @spec[:paths][path][@route.request_method.to_s] = @route_spec
53
64
 
54
65
  add_responses
55
66
  end
@@ -79,6 +90,63 @@ module Apia
79
90
  ).add_to_spec
80
91
  end
81
92
 
93
+ # Adds a description of the scopes to the route specification.
94
+ #
95
+ # This method checks if the route's endpoint definition has any scopes.
96
+ # If there are scopes, it appends a description of the scopes to the existing route specification description.
97
+ # The description of the scopes is formatted as a markdown list, with each scope represented as a bullet point.
98
+ def add_scopes_description
99
+ return unless @route.endpoint.definition.scopes.any?
100
+
101
+ @route_spec[:description] =
102
+ <<~DESCRIPTION
103
+ #{@route_spec[:description]}
104
+ ## Scopes
105
+ #{@route.endpoint.definition.scopes.map do |scope|
106
+ "- `#{scope}`"
107
+ end.join("\n")}
108
+ DESCRIPTION
109
+
110
+ @spec[:security].each do |auth|
111
+ auth.each_key do |key|
112
+ scope_prefix = @spec[:components][:securitySchemes][key][:"x-scope-prefix"]
113
+ next unless scope_prefix.present?
114
+
115
+ @route_spec[:description] =
116
+ <<~DESCRIPTION
117
+ #{@route_spec[:description]}
118
+ ### #{key} Scopes
119
+ When using #{key} authentication, scopes are prefixed with `#{scope_prefix}`.
120
+ DESCRIPTION
121
+ end
122
+ end
123
+ end
124
+
125
+ # Adds scopes security to the OpenAPI path specification.
126
+ #
127
+ # This method checks if the route's endpoint definition has any scopes defined.
128
+ # If scopes are present, it iterates over the security schemes in the OpenAPI
129
+ # specification and adds the corresponding scopes to the route's security section.
130
+ #
131
+ # @return [void]
132
+ def add_scopes_security
133
+ unless @route.endpoint.definition.scopes.any?
134
+ @route_spec.delete(:security)
135
+ return
136
+ end
137
+
138
+ @spec[:security].each do |auth|
139
+ auth.each_key do |key|
140
+ scopes = @route.endpoint.definition.scopes
141
+ if scope_prefix = @spec[:components][:securitySchemes][key][:"x-scope-prefix"]
142
+ scopes = scopes.map { |v| "#{scope_prefix}/#{v}" }
143
+ end
144
+
145
+ @route_spec[:security] << { key => scopes }
146
+ end
147
+ end
148
+ end
149
+
82
150
  # It's worth creating a 'nice' operationId for each route, as this is used as the
83
151
  # basis for the method name when calling the endpoint using a generated client.
84
152
  def convert_route_to_id
@@ -126,6 +194,31 @@ module Apia
126
194
  "#{first_part}#{last_part}"
127
195
  end
128
196
 
197
+ # Returns an array of tags representing the group hierarchy for a given group.
198
+ #
199
+ # @param group [Group] The group for which to retrieve the tags.
200
+ # @return [Array<String>] An array of tags representing the group hierarchy.
201
+ def get_group_tags(group)
202
+ tags = []
203
+ current_group = group
204
+
205
+ while current_group
206
+ # Add tags to the spec global tags if they don't already exist
207
+ # Include a description if the group has one.
208
+ unless @spec[:tags].any? { |t| t[:name] == current_group.name }
209
+ global_tag = { name: current_group.name }
210
+ global_tag[:description] = current_group.description if current_group.description
211
+ @spec[:tags] << global_tag
212
+
213
+ end
214
+
215
+ tags.unshift(current_group.name)
216
+ current_group = current_group.parent
217
+ end
218
+
219
+ tags
220
+ end
221
+
129
222
  end
130
223
  end
131
224
  end
@@ -29,6 +29,18 @@ module Apia
29
29
  @options[:base_url] || "https://api.example.com/api/v1"
30
30
  end
31
31
 
32
+ def security_schemes
33
+ @options[:security_schemes] || {}
34
+ end
35
+
36
+ def external_docs
37
+ @options[:external_docs] || {}
38
+ end
39
+
40
+ def info
41
+ @options[:info] || {}
42
+ end
43
+
32
44
  def call(env)
33
45
  if @options[:hosts]&.none? { |host| host == env["HTTP_HOST"] }
34
46
  return @app.call(env)
@@ -38,10 +50,15 @@ module Apia
38
50
  return @app.call(env)
39
51
  end
40
52
 
41
- specification = Specification.new(api_class, base_url, @options[:name])
53
+ specification = Specification.new(api_class, base_url, @options[:name],
54
+ {
55
+ info: info,
56
+ external_docs: external_docs,
57
+ security_schemes: security_schemes
58
+ })
42
59
  body = specification.json
43
60
 
44
- [200, { "Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s }, [body]]
61
+ [200, { "content-type" => "application/json", "content-length" => body.bytesize.to_s }, [body]]
45
62
  end
46
63
 
47
64
  end
@@ -14,21 +14,33 @@ module Apia
14
14
 
15
15
  OPEN_API_VERSION = "3.0.0" # The Ruby client generator currently only supports v3.0.0 https://openapi-generator.tech/
16
16
 
17
- def initialize(api, base_url, name)
17
+ def initialize(api, base_url, name, additions = {})
18
+ default_additions = { info: {}, external_docs: {}, security_schemes: {} }
19
+ additions = default_additions.merge(additions)
20
+
18
21
  @api = api
19
22
  @base_url = base_url
20
23
  @name = name || "Core" # will be suffixed with 'Api' and used in the client generator
21
24
  @spec = {
22
25
  openapi: OPEN_API_VERSION,
23
- info: {},
26
+ info: additions[:info],
27
+ externalDocs: additions[:external_docs],
24
28
  servers: [],
25
29
  paths: {},
26
30
  components: {
27
31
  schemas: {}
28
32
  },
29
- security: []
33
+ security: [],
34
+ tags: [],
35
+ "x-tagGroups": []
30
36
  }
31
37
 
38
+ if @spec[:externalDocs].nil? || @spec[:externalDocs].empty?
39
+ @spec.delete(:externalDocs)
40
+ end
41
+
42
+ add_additional_security_schemes(additions[:security_schemes])
43
+
32
44
  # path_ids is used to keep track of all the IDs of all the paths we've generated, to avoid duplicates
33
45
  # refer to the Path object for more info
34
46
  @path_ids = []
@@ -41,20 +53,31 @@ module Apia
41
53
 
42
54
  private
43
55
 
56
+ def sort_hash_by_nested_tag(hash)
57
+ hash.sort_by do |_, nested_hash|
58
+ nested_hash.values.first[:tags]&.first
59
+ end.to_h
60
+ end
61
+
44
62
  def build_spec
45
63
  add_info
46
64
  add_servers
47
- add_paths
48
65
  add_security
66
+ add_paths
67
+ add_tag_groups
68
+
69
+ @spec[:paths] = sort_hash_by_nested_tag(@spec[:paths])
49
70
  end
50
71
 
51
72
  def add_info
52
73
  title = @api.definition.name || @api.definition.id
53
- @spec[:info] = {
54
- version: "1.0.0",
55
- title: title
56
- }
57
- @spec[:info][:description] = @api.definition.description || "Welcome to the documentation for the #{title}"
74
+ @spec[:info][:version] = "1.0.0" if @spec[:info][:version].nil?
75
+ @spec[:info][:title] = title if @spec[:info][:title].nil?
76
+
77
+ return unless @spec[:info][:description].nil?
78
+
79
+ @spec[:info][:description] =
80
+ @api.definition.description || "Welcome to the documentation for the #{title}"
58
81
  end
59
82
 
60
83
  def add_servers
@@ -85,6 +108,71 @@ module Apia
85
108
  end
86
109
  end
87
110
 
111
+ def get_tag_group_index(tag)
112
+ @spec[:"x-tagGroups"].each_with_index do |group, index|
113
+ return index if !group.nil? && group[:name] == tag
114
+ end
115
+
116
+ nil
117
+ end
118
+
119
+ # Adds tag groups to the OpenAPI specification.
120
+ #
121
+ # This method iterates over the paths in the specification and adds tag groups
122
+ # based on the tags specified for each method.
123
+ # It ensures that each tag is included in the `tags` array of the specification,
124
+ # and creates tag groups based on the order of the tags.
125
+ # Tag groups are represented by the `x-tagGroups` property in the specification.
126
+ # Tag groups are nested groups and are used to group tags together in documentation.
127
+ # A Tag *must* be included in a tag group. So if it is not part of a group / has no parent tag,
128
+ # it will be added to a group with the same name as the tag.
129
+ #
130
+ # @return [void]
131
+ def add_tag_groups
132
+ @spec[:paths].each_value do |methods|
133
+ methods.each_value do |method_spec|
134
+ tags = method_spec[:tags]
135
+
136
+ tags.each_with_index do |tag, tag_index|
137
+ next if tag_index.zero?
138
+
139
+ parent_tag = tags[tag_index - 1]
140
+ parent_index = get_tag_group_index(parent_tag)
141
+
142
+ if parent_index.nil? && tags.size > 1
143
+ @spec[:"x-tagGroups"] << { name: parent_tag, tags: [parent_tag] }
144
+ parent_index = @spec[:"x-tagGroups"].size - 1
145
+ end
146
+
147
+ unless @spec[:"x-tagGroups"][parent_index][:tags].include?(tag)
148
+ @spec[:"x-tagGroups"][parent_index][:tags] << tag
149
+ end
150
+ end
151
+
152
+ # Set the last tag as the tag for the method
153
+ # After we have built the tag group.
154
+ method_spec[:tags] = [tags.last] if tags.any?
155
+ end
156
+ end
157
+
158
+ @spec[:tags].each do |tag|
159
+ unless @spec[:"x-tagGroups"].any? { |group| group[:tags].include?(tag[:name]) }
160
+ @spec[:"x-tagGroups"] << { name: tag[:name], tags: [tag[:name]] }
161
+ end
162
+ end
163
+
164
+ @spec[:"x-tagGroups"].sort_by! { |group| group[:name] }
165
+ @spec[:"x-tagGroups"].each { |group| group[:tags].sort! }
166
+ end
167
+
168
+ def add_additional_security_schemes(security_schemes)
169
+ security_schemes.each do |key, value|
170
+ @spec[:components][:securitySchemes] ||= {}
171
+ @spec[:components][:securitySchemes][key] = value
172
+ @spec[:security] << { key => [] }
173
+ end
174
+ end
175
+
88
176
  end
89
177
  end
90
178
  end
@@ -3,7 +3,7 @@
3
3
  module Apia
4
4
  module OpenApi
5
5
 
6
- VERSION = "0.1.8"
6
+ VERSION = "0.1.10"
7
7
 
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: apia-open_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paul Sturgess
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-21 00:00:00.000000000 Z
11
+ date: 2024-08-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport