apia-open_api 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4f5de7b599466adb38eeeb825b2688ca64460a5c10c517141775af93355d35f6
4
+ data.tar.gz: 285a0dfa241e201f1df2370553294cd7514b9ef339060a3f9b0ff5d3268a7064
5
+ SHA512:
6
+ metadata.gz: cce09e236df6739ae7d4480597d4dbe012c901bd3aa5e4ebc38f49c19915917997737f3b5d2bee4fc09ec82a9223842fb84793b7cf746994dc2f37dac7d115cb
7
+ data.tar.gz: e02b8c2ff50507d65cb45a0b67ab189782174e5c95dae20b29d11c671e3562e1c6dcf80c1950fad5276eff3cf4fdc6975b761ac81f3b8806379b225c6cec91e2
data/Gemfile ADDED
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in apia-open_api.gemspec
6
+ gemspec
7
+
8
+ gem "apia", "~> 3.5"
9
+ gem "rake", "~> 13.0"
10
+ gem "rspec", "~> 3.0"
11
+ gem "rubocop", "~> 1.21"
12
+
13
+ group :test do
14
+ gem "pry"
15
+ gem "simplecov", require: false
16
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Krystal Hosting Ltd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # Apia OpenAPI Specification
2
+
3
+ This gem can generate an [OpenAPI](https://www.openapis.org/) compatible schema from an API implemented using [Apia](https://github.com/krystal/apia).
4
+
5
+ ## Installation
6
+
7
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
8
+
9
+ Install the gem and add to the application's Gemfile by executing:
10
+
11
+ $ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
12
+
13
+ If bundler is not being used to manage dependencies, install the gem by executing:
14
+
15
+ $ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
16
+
17
+ ## Usage
18
+
19
+ The schema can be mounted in much the same way as an [Apia API](https://github.com/krystal/apia) itself.
20
+
21
+ For example, for a Ruby on Rails application:
22
+
23
+ ```ruby
24
+ module MyApp
25
+ class Application < Rails::Application
26
+
27
+ config.middleware.use Apia::OpenApi::Rack,
28
+ api_class: "CoreAPI::Base",
29
+ schema_path: "/core/v1/schema/openapi.json",
30
+ base_url: "http://katapult-api.localhost/core/v1"
31
+
32
+ end
33
+ end
34
+ ```
35
+
36
+ Where `CoreAPI::Base` is the name of the API class that inherits from `Apia::API`.
37
+
38
+ ## Generating a client library from the spec
39
+
40
+ It's possible to generate a client library from the generated OpenAPI schema using [OpenAPI Generator](https://openapi-generator.tech/).
41
+
42
+ For example we can generate a Ruby client with the following:
43
+
44
+ ```bash
45
+ brew install openapi-generator
46
+ openapi-generator generate -i openapi.json -g ruby -o openapi-client --additional-properties=gemName=myapp-openapi-client,moduleName=MyAppOpenAPIClient
47
+ ```
48
+
49
+ The generated client will be in the `openapi-client` directory and will contain a readme with instructions on how to use it.
50
+
51
+ ## Development
52
+
53
+ After checking out the repo, run `bin/setup` to install dependencies.
54
+
55
+ In `/examples` there is an example Apia API application that can be used to try out the gem.
56
+
57
+ Run `rackup` from the root of `/examples` to start the [rack app](https://github.com/rack/rack) running the example API.
58
+ To view the generated OpenAPI schema, visit: http://127.0.0.1:9292/core/v1/schema/openapi.json
59
+ `/examples/config.ru` shows how to mount the schema endpoint.
60
+
61
+ The generated schema can be viewed, validated and tried out using the online [Swagger Editor](https://editor.swagger.io/). You'll need to add the bearer token to the swagger editor to authenticate the requests. After that, they should work as expected. The bearer token is defined in main_authenticator.rb.
62
+
63
+ Currently the online swagger-editor only allows the OpenAPI schema v3.0.0. But it's also possible to run the swagger-editor locally, which allows us to check against v3.1.0.
64
+
65
+ e.g with this docker-compose.yml file:
66
+
67
+ ```yml
68
+ version: "3.3"
69
+ services:
70
+ swagger-editor:
71
+ image: swaggerapi/swagger-editor:next-v5
72
+ container_name: "swagger-editor"
73
+ ports:
74
+ - "8081:80"
75
+ ```
76
+
77
+ Run `docker-compose up` and visit http://localhost:8081 to view the swagger editor.
78
+
79
+ ### Tests and Linting
80
+
81
+ - `bin/rspec`
82
+ - `bin/rubocop`
83
+
84
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
85
+
86
+ ## Releasing a new version
87
+
88
+ TODO: Write instructions for releasing a new version of the gem.
89
+
90
+ ## Contributing
91
+
92
+ Bug reports and pull requests are welcome on GitHub at https://github.com/krystal/apia-open_api.
93
+
94
+ ## License
95
+
96
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/apia/open_api/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "apia-open_api"
7
+ spec.version = Apia::OpenApi::VERSION
8
+ spec.authors = ["Paul Sturgess"]
9
+
10
+ spec.summary = "Apia OpenAPI spec generator"
11
+ spec.homepage = "https://github.com/krystal/apia-openapi"
12
+ spec.license = "MIT"
13
+ spec.required_ruby_version = ">= 2.7.0"
14
+
15
+ spec.metadata["homepage_uri"] = spec.homepage
16
+ spec.metadata["source_code_uri"] = "https://github.com/krystal/apia-openapi"
17
+ spec.metadata["changelog_uri"] = "https://github.com/krystal/apia-openapi/changelog.md"
18
+
19
+ spec.metadata["rubygems_mfa_required"] = "false" # rubocop:disable Gemspec/RequireMFA (enabling MFA means we cannot auto publish via the CI)
20
+
21
+ spec.files = Dir[File.join("lib", "**", "*.rb")] +
22
+ Dir["{*.gemspec,Gemfile,Rakefile,README.*,LICENSE*}"]
23
+ spec.bindir = "exe"
24
+ spec.executables = spec.files.grep(/\Aexe\//) { |f| File.basename(f) }
25
+ spec.require_paths = ["lib"]
26
+
27
+ spec.add_dependency "activesupport", ">= 6"
28
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A collection of 'utility' methods used across the various OpenAPI::Objects
4
+ module Apia
5
+ module OpenApi
6
+ module Helpers
7
+
8
+ # A component schema is a re-usable schema that can be referenced by other parts of the spec
9
+ # e.g. { "$ref": "#/components/schemas/PaginationObject" }
10
+ def add_to_components_schemas(definition, id, **schema_opts)
11
+ return true unless @spec.dig(:components, :schemas, id).nil?
12
+
13
+ component_schema = {}
14
+ @spec[:components][:schemas][id] = component_schema
15
+ Objects::Schema.new(
16
+ spec: @spec,
17
+ definition: definition,
18
+ schema: component_schema,
19
+ id: id,
20
+ **schema_opts
21
+ ).add_to_spec
22
+
23
+ return true if component_schema.present?
24
+
25
+ @spec[:components][:schemas].delete(id)
26
+ false
27
+ end
28
+
29
+ def convert_type_to_open_api_data_type(type)
30
+ case type.klass.to_s
31
+ when "Apia::Scalars::String", "Apia::Scalars::Base64", "Apia::Scalars::Date"
32
+ "string"
33
+ when "Apia::Scalars::Integer", "Apia::Scalars::UnixTime"
34
+ "integer"
35
+ when "Apia::Scalars::Decimal"
36
+ "number"
37
+ when "Apia::Scalars::Boolean"
38
+ "boolean"
39
+ else
40
+ raise "Unknown Apia type #{type.klass} mapping to OpenAPI type"
41
+ end
42
+ end
43
+
44
+ def generate_scalar_schema(definition)
45
+ type = definition.type
46
+ schema = {
47
+ type: convert_type_to_open_api_data_type(type)
48
+ }
49
+ schema[:description] = definition.description if definition.description.present?
50
+ schema[:format] = "float" if type.klass == Apia::Scalars::Decimal
51
+ schema[:format] = "date" if type.klass == Apia::Scalars::Date
52
+ schema
53
+ end
54
+
55
+ def generate_schema_ref(definition, id: nil, **schema_opts)
56
+ id ||= generate_id_from_definition(definition.type.klass.definition)
57
+ success = add_to_components_schemas(definition, id, **schema_opts)
58
+
59
+ if success
60
+ { "$ref": "#/components/schemas/#{id}" }
61
+ else # no properties were defined, so just declare an object with unknown properties
62
+ { type: "object" }
63
+ end
64
+ end
65
+
66
+ def generate_id_from_definition(definition)
67
+ definition.id.split("/").last
68
+ end
69
+
70
+ def formatted_description(description)
71
+ return description if description.end_with?(".")
72
+
73
+ "#{description}."
74
+ end
75
+
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The BearerSecurityScheme object defines the required spec for authentication via a bearer token.
4
+ #
5
+ # "components": {
6
+ # "securitySchemes": {
7
+ # "CoreAPI_Authenticator": {
8
+ # "scheme": "bearer",
9
+ # "type": "http"
10
+ # }
11
+ # }
12
+ # },
13
+ # "security": [
14
+ # {
15
+ # "CoreAPI_Authenticator": []
16
+ # }
17
+ # ]
18
+
19
+ module Apia
20
+ module OpenApi
21
+ module Objects
22
+ class BearerSecurityScheme
23
+
24
+ include Apia::OpenApi::Helpers
25
+
26
+ def initialize(spec:, authenticator:)
27
+ @spec = spec
28
+ @authenticator = authenticator
29
+ end
30
+
31
+ def add_to_spec
32
+ @spec[:components][:securitySchemes] ||= {}
33
+ @spec[:components][:securitySchemes][generate_id_from_definition(@authenticator.definition)] = {
34
+ scheme: "bearer",
35
+ type: "http"
36
+ }
37
+
38
+ @spec[:security] << {
39
+ generate_id_from_definition(@authenticator.definition) => []
40
+ }
41
+ end
42
+
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Parameters describe the arguments that can be passed to an endpoint via the query string.
4
+ # We only declare these for GET requests, otherwise we define a request body.
5
+ #
6
+ # "parameters": [
7
+ # {
8
+ # "name": "data_center[id]",
9
+ # "in": "query",
10
+ # "schema": {
11
+ # "type": "string"
12
+ # }
13
+ # },
14
+ # {
15
+ # "name": "data_center[permalink]",
16
+ # "in": "query",
17
+ # "schema": {
18
+ # "type": "string"
19
+ # }
20
+ # }
21
+ # ]
22
+ module Apia
23
+ module OpenApi
24
+ module Objects
25
+ class Parameters
26
+
27
+ include Apia::OpenApi::Helpers
28
+
29
+ def initialize(spec:, argument:, route_spec:)
30
+ @spec = spec
31
+ @argument = argument
32
+ @route_spec = route_spec
33
+ end
34
+
35
+ def add_to_spec
36
+ if @argument.type.argument_set?
37
+ generate_argument_set_params
38
+ elsif @argument.array?
39
+ if @argument.type.enum? || @argument.type.object?
40
+ items = generate_schema_ref(@argument)
41
+ else
42
+ items = generate_scalar_schema(@argument)
43
+ end
44
+
45
+ param = {
46
+ name: "#{@argument.name}[]",
47
+ in: "query",
48
+ schema: {
49
+ type: "array",
50
+ items: items
51
+ }
52
+ }
53
+ param[:description] = @argument.description if @argument.description.present?
54
+ param[:required] = true if @argument.required?
55
+ add_to_parameters(param)
56
+ elsif @argument.type.enum?
57
+ param = {
58
+ name: @argument.name.to_s,
59
+ in: "query",
60
+ schema: generate_schema_ref(@argument)
61
+ }
62
+ param[:description] = @argument.description if @argument.description.present?
63
+ param[:required] = true if @argument.required?
64
+ add_to_parameters(param)
65
+ else
66
+ param = {
67
+ name: @argument.name.to_s,
68
+ in: "query",
69
+ schema: generate_scalar_schema(@argument)
70
+ }
71
+ param[:description] = @argument.description if @argument.description.present?
72
+ param[:required] = true if @argument.required?
73
+ add_to_parameters(param)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ # Complex argument sets are not supported in query params (e.g. nested objects)
80
+ # For any LookupArgumentSet only one argument is expected to be provided.
81
+ # However, OpenAPI does not currently support describing mutually exclusive query params.
82
+ # refer to: https://swagger.io/docs/specification/describing-parameters/#dependencies
83
+ def generate_argument_set_params
84
+ @argument.type.klass.definition.arguments.each_value do |child_arg|
85
+ param = {
86
+ name: "#{@argument.name}[#{child_arg.name}]",
87
+ in: "query",
88
+ schema: generate_scalar_schema(child_arg)
89
+ }
90
+ description = []
91
+ description << formatted_description(@argument.description) if @argument.description.present?
92
+ description << formatted_description(child_arg.description) if child_arg.description.present?
93
+ description << "All '#{@argument.name}[]' params are mutually exclusive, only one can be provided."
94
+ param[:description] = description.join(" ")
95
+ add_to_parameters(param)
96
+ end
97
+ end
98
+
99
+ def add_to_parameters(param)
100
+ @route_spec[:parameters] << param
101
+ end
102
+
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A Path Object describes a single endpoint in the API
4
+ #
5
+ # "paths": {
6
+ # "/data_centers/:data_center": {
7
+ # "get": {
8
+ # "operationId": "get:data_center",
9
+ # "tags": ["Core"],
10
+ # "parameters": [...],
11
+ # "responses": {...},
12
+ # }
13
+ # },
14
+ # "/virtual_machines/:virtual_machine/start": {
15
+ # "post": {
16
+ # "operationId": "post:virtual_machine_start",
17
+ # "requestBody": {...}
18
+ # "responses": {
19
+ # "200": {...}
20
+ # }
21
+ # }
22
+ # }
23
+ # }
24
+
25
+ module Apia
26
+ module OpenApi
27
+ module Objects
28
+ class Path
29
+
30
+ include Apia::OpenApi::Helpers
31
+
32
+ def initialize(spec:, path_ids:, route:, name:, api_authenticator:)
33
+ @spec = spec
34
+ @path_ids = path_ids
35
+ @route = route
36
+ @api_authenticator = api_authenticator
37
+ @route_spec = {
38
+ operationId: convert_route_to_id,
39
+ tags: [name]
40
+ }
41
+ end
42
+
43
+ def add_to_spec
44
+ path = @route.path
45
+ if @route.request_method == :get
46
+ add_parameters
47
+ else
48
+ add_request_body
49
+ end
50
+
51
+ @spec[:paths]["/#{path}"] ||= {}
52
+ @spec[:paths]["/#{path}"][@route.request_method.to_s] = @route_spec
53
+
54
+ add_responses
55
+ end
56
+
57
+ private
58
+
59
+ # aka query params
60
+ def add_parameters
61
+ @route_spec[:parameters] ||= []
62
+
63
+ @route.endpoint.definition.argument_set.definition.arguments.each_value do |arg|
64
+ Parameters.new(spec: @spec, argument: arg, route_spec: @route_spec).add_to_spec
65
+ end
66
+ end
67
+
68
+ def add_request_body
69
+ RequestBody.new(spec: @spec, route: @route, route_spec: @route_spec).add_to_spec
70
+ end
71
+
72
+ def add_responses
73
+ Response.new(
74
+ spec: @spec,
75
+ path_ids: @path_ids,
76
+ route: @route,
77
+ route_spec: @route_spec,
78
+ api_authenticator: @api_authenticator
79
+ ).add_to_spec
80
+ end
81
+
82
+ # It's worth creating a 'nice' operationId for each route, as this is used as the
83
+ # basis for the method name when calling the endpoint using a generated client.
84
+ def convert_route_to_id
85
+ parts = @route.path.split("/")
86
+ params = parts.each_with_object([]) do |part, memo|
87
+ memo << part[1..] if part.start_with?(":")
88
+ end
89
+ result_parts = []
90
+
91
+ parts.each do |part|
92
+ if part.start_with?(":")
93
+ part_without_prefix = part[1..]
94
+ next if result_parts.include?(part_without_prefix)
95
+
96
+ result_parts << part_without_prefix
97
+ elsif params.none? { |param| part == param || part.match(/#{param.pluralize}/) }
98
+ result_parts << part
99
+ end
100
+ end
101
+
102
+ id = "#{@route.request_method}:#{result_parts.join('_')}"
103
+ if @path_ids.include?(id)
104
+ id = "#{@route.request_method}:#{@route.path}"
105
+ end
106
+ @path_ids << id
107
+
108
+ id
109
+ end
110
+
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A RequestBody Object describes the payload body that can be passed to an endpoint
4
+ # These are declared (if necessary) for any endpoint that is not a GET request.
5
+ #
6
+ # "requestBody": {
7
+ # "content": {
8
+ # "application/json": {
9
+ # "schema": {
10
+ # "properties": {
11
+ # "virtual_machine": {
12
+ # "$ref": "#/components/schemas/CoreAPI_ArgumentSets_VirtualMachineLookup"
13
+ # }
14
+ # }
15
+ # }
16
+ # }
17
+ # }
18
+ # }
19
+ module Apia
20
+ module OpenApi
21
+ module Objects
22
+ class RequestBody
23
+
24
+ include Apia::OpenApi::Helpers
25
+
26
+ def initialize(spec:, route:, route_spec:)
27
+ @spec = spec
28
+ @route = route
29
+ @route_spec = route_spec
30
+ @properties = {}
31
+ end
32
+
33
+ def add_to_spec
34
+ required = []
35
+ @route.endpoint.definition.argument_set.definition.arguments.each_value do |arg|
36
+ required << arg.name.to_s if arg.required?
37
+ if arg.array?
38
+ if arg.type.argument_set? || arg.type.enum?
39
+ items = generate_schema_ref(arg)
40
+ else
41
+ items = generate_scalar_schema(arg)
42
+ end
43
+
44
+ @properties[arg.name.to_s] = {
45
+ type: "array",
46
+ items: items
47
+ }
48
+ elsif arg.type.argument_set? || arg.type.enum?
49
+ @properties[arg.name.to_s] = generate_schema_ref(arg)
50
+ else
51
+ @properties[arg.name.to_s] = generate_scalar_schema(arg)
52
+ end
53
+ end
54
+
55
+ schema = { properties: @properties }
56
+ schema[:required] = required unless required.empty?
57
+
58
+ @route_spec[:requestBody] = {
59
+ content: {
60
+ "application/json": {
61
+ schema: schema
62
+ }
63
+ }
64
+ }
65
+ end
66
+
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ # For each endpoint, we need to generate a response schema.
4
+ #
5
+ # "responses": {
6
+ # "200": {
7
+ # "description": "Format the given time",
8
+ # "content": {
9
+ # "application/json": {
10
+ # "schema": {
11
+ # "properties": {
12
+ # "formatted_time": {
13
+ # "type": "string"
14
+ # }
15
+ # },
16
+ # "required": [
17
+ # "formatted_time"
18
+ # ]
19
+ # }
20
+ # }
21
+ # }
22
+ # }
23
+ # }
24
+ module Apia
25
+ module OpenApi
26
+ module Objects
27
+ class Response
28
+
29
+ include Apia::OpenApi::Helpers
30
+
31
+ def initialize(spec:, path_ids:, route:, route_spec:, api_authenticator:)
32
+ @spec = spec
33
+ @path_ids = path_ids
34
+ @route = route
35
+ @endpoint = route.endpoint
36
+ @route_spec = route_spec
37
+ @api_authenticator = api_authenticator
38
+ @http_status = @endpoint.definition.http_status
39
+ end
40
+
41
+ def add_to_spec
42
+ add_sucessful_response_schema
43
+ add_error_response_schemas
44
+ end
45
+
46
+ private
47
+
48
+ def add_sucessful_response_schema
49
+ content_schema = {
50
+ properties: generate_properties_for_successful_response
51
+ }
52
+ required_fields = @endpoint.definition.fields.select { |_, field| field.condition.nil? }
53
+ content_schema[:required] = required_fields.keys if required_fields.any?
54
+
55
+ @route_spec[:responses] = {
56
+ "#{@http_status}": {
57
+ description: @endpoint.definition.description || "",
58
+ content: {
59
+ "application/json": {
60
+ schema: content_schema
61
+ }
62
+ }
63
+ }
64
+ }
65
+ end
66
+
67
+ def generate_properties_for_successful_response
68
+ @endpoint.definition.fields.reduce({}) do |props, (name, field)|
69
+ props.merge(generate_properties_for_field(name, field))
70
+ end
71
+ end
72
+
73
+ # Response fields can often just point to a ref of a schema. But it's also possible to reference
74
+ # a return type and not include all fields of that type. If `field.include` is defined, we need
75
+ # to inspect it to determine which fields are included.
76
+ def generate_properties_for_field(field_name, field)
77
+ properties = {}
78
+ if field.type.polymorph?
79
+ build_properties_for_polymorph(field_name, field, properties)
80
+ elsif field.array?
81
+ build_properties_for_array(field_name, field, properties)
82
+ elsif field.type.object? || field.type.enum?
83
+ build_properties_for_object_or_enum(field_name, field, properties)
84
+ else
85
+ properties[field_name] = generate_scalar_schema(field)
86
+ end
87
+ properties[field_name][:nullable] = true if field.null?
88
+ properties
89
+ end
90
+
91
+ def build_properties_for_polymorph(field_name, field, properties)
92
+ if field_includes_all_properties?(field)
93
+ refs = []
94
+ field.type.klass.definition.options.map do |_, polymorph_option|
95
+ refs << generate_schema_ref(polymorph_option)
96
+ end
97
+ properties[field_name] = { oneOf: refs }
98
+ else
99
+ # We assume the partially selected attributes must be present in all of the polymorph options
100
+ # and that each option returns the same data type for that attribute.
101
+ # The same 'allOf workaround' is used here as for objects and enums below.
102
+ ref = generate_schema_ref(
103
+ field.type.klass.definition.options.values.first,
104
+ id: generate_field_id(field_name),
105
+ endpoint: @endpoint,
106
+ path: [field]
107
+ )
108
+
109
+ properties[field_name] = { allOf: [ref] }
110
+ end
111
+ properties[field_name][:description] = field.description if field.description.present?
112
+ end
113
+
114
+ def build_properties_for_array(field_name, field, properties)
115
+ if field.type.object? || field.type.enum?
116
+ if field_includes_all_properties?(field)
117
+ items = generate_schema_ref(field)
118
+ else
119
+ items = generate_schema_ref(
120
+ field,
121
+ id: generate_field_id(field_name),
122
+ endpoint: @endpoint,
123
+ path: [field]
124
+ )
125
+ end
126
+ else
127
+ items = { type: convert_type_to_open_api_data_type(field.type) }
128
+ end
129
+ return unless items
130
+
131
+ properties[field_name] = {
132
+ type: "array",
133
+ items: items
134
+ }
135
+ properties[field_name][:description] = field.description if field.description.present?
136
+ end
137
+
138
+ # Using allOf is a 'workaround' so that we can include a description for the field
139
+ # In OpenAPI 3.0 sibling properties are not allowed for $refs (but are allowed in 3.1)
140
+ # We don't want to put the description on the $ref itself because the description is
141
+ # specific to the endpoint and not necessarily applicable to all uses of the $ref.
142
+ def build_properties_for_object_or_enum(field_name, field, properties)
143
+ properties[field_name] = {}
144
+ properties[field_name][:description] = field.description if field.description.present?
145
+ if field_includes_all_properties?(field)
146
+ ref = generate_schema_ref(field)
147
+ else
148
+ ref = generate_schema_ref(
149
+ field,
150
+ id: generate_field_id(field_name),
151
+ endpoint: @endpoint,
152
+ path: [field]
153
+ )
154
+ end
155
+ properties[field_name][:allOf] = [ref]
156
+ end
157
+
158
+ def field_includes_all_properties?(field)
159
+ field.include.nil?
160
+ end
161
+
162
+ def generate_field_id(field_name)
163
+ [
164
+ @route_spec[:operationId].gsub(":", "_").gsub("/", "_").camelize,
165
+ @http_status,
166
+ "Response",
167
+ field_name.to_s
168
+ ].flatten.join("_").camelize
169
+ end
170
+
171
+ def add_error_response_schemas
172
+ grouped_potential_errors = potential_errors.map(&:definition).group_by do |d|
173
+ d.http_status_code.to_s.to_sym
174
+ end
175
+
176
+ sorted_grouped_potential_errors = grouped_potential_errors.sort_by do |http_status_code, _|
177
+ http_status_code.to_s.to_i
178
+ end
179
+
180
+ sorted_grouped_potential_errors.each do |http_status_code, potential_errors|
181
+ add_error_response_schema_for_http_status_code(http_status_code, potential_errors)
182
+ end
183
+ end
184
+
185
+ def api_authenticator_potential_errors
186
+ @api_authenticator&.definition&.potential_errors
187
+ end
188
+
189
+ def potential_errors
190
+ argument_set = @endpoint.definition.argument_set
191
+ lookup_argument_set_errors = argument_set.collate_objects(Apia::ObjectSet.new).values.map do |o|
192
+ o.type.klass.definition.try(:potential_errors)
193
+ end.compact
194
+
195
+ [
196
+ api_authenticator_potential_errors,
197
+ @route.controller&.definition&.authenticator&.definition&.potential_errors,
198
+ @endpoint.definition&.authenticator&.definition&.potential_errors,
199
+ lookup_argument_set_errors,
200
+ @endpoint.definition.potential_errors
201
+ ].compact.flatten
202
+ end
203
+
204
+ def add_error_response_schema_for_http_status_code(http_status_code, potential_errors)
205
+ response_schema = generate_potential_error_ref(http_status_code, potential_errors)
206
+ @route_spec[:responses].merge!(response_schema)
207
+ end
208
+
209
+ def generate_potential_error_ref(http_status_code, potential_errors)
210
+ { "#{http_status_code}": generate_ref("responses", http_status_code, potential_errors) }
211
+ end
212
+
213
+ def generate_ref(namespace, http_status_code, definitions)
214
+ id = generate_id_for_error_ref(http_status_code, definitions)
215
+ if namespace == "responses"
216
+ add_to_responses_components(http_status_code, definitions, id)
217
+ else
218
+ add_to_schemas_components(definitions.first, id)
219
+ end
220
+ { "$ref": "#/components/#{namespace}/#{id}" }
221
+ end
222
+
223
+ def generate_id_for_error_ref(http_status_code, definitions)
224
+ api_authenticator_error_defs = api_authenticator_potential_errors.map(&:definition).select do |d|
225
+ d.http_status_code.to_s == http_status_code.to_s
226
+ end
227
+ if api_authenticator_error_defs.any? && api_authenticator_error_defs == definitions
228
+ "APIAuthenticator#{http_status_code}Response"
229
+ elsif definitions.length == 1
230
+ "#{generate_id_from_definition(definitions.first)}Response"
231
+ else
232
+ [
233
+ (definitions - api_authenticator_error_defs).map do |d|
234
+ generate_id_from_definition(d)
235
+ end.join,
236
+ http_status_code,
237
+ "Response"
238
+ ].flatten.join("_").camelize
239
+ end
240
+ end
241
+
242
+ def add_to_responses_components(http_status_code, definitions, id)
243
+ return unless @spec.dig(:components, :components, id).nil?
244
+
245
+ component_schema = {
246
+ description: "#{http_status_code} error response"
247
+ }
248
+
249
+ if definitions.length == 1
250
+ definition = definitions.first
251
+ component_schema[:description] = definition.description if definition.description.present?
252
+ schema = generate_schema_properties_for_definition(definition)
253
+ else # the same http status code is used for multiple errors
254
+ one_of_id = "OneOf#{id}"
255
+ @spec[:components][:schemas][one_of_id] = {
256
+ oneOf: definitions.map { |d| generate_ref("schemas", http_status_code, [d]) }
257
+ }
258
+
259
+ schema = { "$ref": "#/components/schemas/#{one_of_id}" }
260
+ end
261
+
262
+ component_schema[:content] = {
263
+ "application/json": {
264
+ schema: schema
265
+ }
266
+ }
267
+ @spec[:components][:responses] ||= {}
268
+ @spec[:components][:responses][id] = component_schema
269
+ component_schema
270
+ end
271
+
272
+ def generate_schema_properties_for_definition(definition)
273
+ detail = generate_schema_ref(definition, id: generate_id_from_definition(definition))
274
+ {
275
+ properties: {
276
+ code: {
277
+ type: "string",
278
+ enum: [definition.code]
279
+ },
280
+ description: { type: "string" },
281
+ detail: detail
282
+ }
283
+ }
284
+ end
285
+
286
+ def add_to_schemas_components(definition, id)
287
+ @spec[:components][:schemas] ||= {}
288
+ component_schema = {
289
+ type: "object"
290
+ }
291
+ component_schema[:description] = definition.description if definition.description.present?
292
+ component_schema.merge!(generate_schema_properties_for_definition(definition))
293
+ @spec[:components][:schemas][id] = component_schema
294
+ component_schema
295
+ end
296
+
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Schema Objects loosley map to the re-usable parts of an Apia API, such as:
4
+ # ArgumentSet, Object, Enum, Polymorph
5
+ #
6
+ # "PaginationObject": {
7
+ # "type": "object",
8
+ # "properties": {
9
+ # "current_page": {
10
+ # "type": "integer"
11
+ # },
12
+ # "total_pages": {
13
+ # "type": "integer"
14
+ # },
15
+ # "total": {
16
+ # "type": "integer"
17
+ # },
18
+ # "per_page": {
19
+ # "type": "integer"
20
+ # },
21
+ # }
22
+ # }
23
+ #
24
+ # We generate Schema Objects for two reasons:
25
+ # 1. They are added to the components section of the spec, so they can be referenced and re-used from elsewhere.
26
+ # e.g. the pagination object example above would be referenced from endpoint responses as:
27
+ # { "$ref": "#/components/schemas/PaginationObject" }
28
+ #
29
+ # 2. When the response does not include all fields from an existing Objects::Schema, we cannot use a $ref.
30
+ # So we generate a new Objects::Schema and include it 'inline' for that specific endpoint.
31
+ module Apia
32
+ module OpenApi
33
+ module Objects
34
+ class Schema
35
+
36
+ include Apia::OpenApi::Helpers
37
+
38
+ def initialize(spec:, definition:, schema:, id:, endpoint: nil, path: nil)
39
+ @spec = spec
40
+ @definition = definition
41
+ @schema = schema
42
+ @id = id
43
+ @endpoint = endpoint
44
+ @path = path
45
+ @children = []
46
+ end
47
+
48
+ def add_to_spec
49
+ if @definition.try(:type)&.polymorph?
50
+ build_schema_for_polymorph
51
+ return @schema
52
+ end
53
+
54
+ generate_child_schemas
55
+ @schema
56
+ end
57
+
58
+ private
59
+
60
+ def build_schema_for_polymorph
61
+ @schema[:type] = "object"
62
+ @schema[:properties] ||= {}
63
+ refs = []
64
+ @definition.type.klass.definition.options.map do |_, polymorph_option|
65
+ refs << generate_schema_ref(polymorph_option)
66
+ end
67
+ @schema[:properties][@definition.name.to_s] = { oneOf: refs }
68
+ end
69
+
70
+ def error_definition?
71
+ @definition.is_a?(Apia::Definitions::Error)
72
+ end
73
+
74
+ def enum_definition?
75
+ @definition.try(:type)&.enum?
76
+ end
77
+
78
+ def generate_child_schemas
79
+ if error_definition?
80
+ @children = @definition.fields.values
81
+ elsif @definition.type.argument_set?
82
+ @children = @definition.type.klass.definition.arguments.values
83
+ @schema[:description] ||=
84
+ "All '#{@definition.name}[]' params are mutually exclusive, only one can be provided."
85
+ elsif @definition.type.object?
86
+ @children = @definition.type.klass.definition.fields.values
87
+ elsif enum_definition?
88
+ @children = @definition.type.klass.definition.values.values
89
+ end
90
+
91
+ return if @children.empty?
92
+
93
+ all_properties_included = error_definition? || enum_definition? || @endpoint.nil?
94
+ @children.each do |child|
95
+ next unless @endpoint.nil? || (!enum_definition? && @endpoint.include_field?(@path + [child]))
96
+
97
+ if child.respond_to?(:array?) && child.array?
98
+ generate_schema_for_child_array(@schema, child, all_properties_included)
99
+ else
100
+ generate_schema_for_child(@schema, child, all_properties_included)
101
+ end
102
+ end
103
+ end
104
+
105
+ def generate_schema_for_child_array(schema, child, all_properties_included)
106
+ child_schema = generate_schema_for_child({}, child, all_properties_included)
107
+ items = child_schema.dig(:properties, child.name.to_s)
108
+ return unless items.present?
109
+
110
+ schema[:properties] ||= {}
111
+ schema[:properties][child.name.to_s] = {
112
+ type: "array",
113
+ items: items
114
+ }
115
+ end
116
+
117
+ def generate_schema_for_child(schema, child, all_properties_included)
118
+ if enum_definition?
119
+ schema[:type] = "string"
120
+ schema[:enum] = @children.map { |c| c[:name] }
121
+ elsif child.type.argument_set? || child.type.enum? || child.type.polymorph?
122
+ schema[:type] = "object"
123
+ schema[:properties] ||= {}
124
+ schema[:properties][child.name.to_s] = generate_schema_ref(child)
125
+ elsif child.type.object?
126
+ generate_properties_for_object(schema, child, all_properties_included)
127
+ else # scalar
128
+ schema[:type] = "object"
129
+ schema[:properties] ||= {}
130
+ schema[:properties][child.name.to_s] = generate_scalar_schema(child)
131
+ end
132
+
133
+ if child.try(:required?)
134
+ schema[:required] ||= []
135
+ schema[:required] << child.name.to_s
136
+ end
137
+ schema
138
+ end
139
+
140
+ def generate_properties_for_object(schema, child, all_properties_included)
141
+ schema[:type] = "object"
142
+ schema[:properties] ||= {}
143
+ if all_properties_included
144
+ schema[:properties][child.name.to_s] = generate_schema_ref(child)
145
+ else
146
+ child_path = @path.nil? ? nil : @path + [child]
147
+ schema[:properties][child.name.to_s] = generate_schema_ref(
148
+ child,
149
+ id: "#{@id}_#{child.name}".camelize,
150
+ endpoint: @endpoint,
151
+ path: child_path
152
+ )
153
+ end
154
+ end
155
+
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ Dir.glob(File.join(File.dirname(__FILE__), "objects", "*.rb")).sort.each do |file|
4
+ require_relative file
5
+ end
6
+
7
+ module Apia
8
+ module OpenApi
9
+ module Objects
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apia
4
+ module OpenApi
5
+ class Rack
6
+
7
+ def initialize(app, api_class:, schema_path:, **options)
8
+ @app = app
9
+ @api_class = api_class
10
+ @schema_path = "/#{schema_path.sub(/\A\/+/, '').sub(/\/+\z/, '')}"
11
+ @options = options
12
+ end
13
+
14
+ def development?
15
+ env_is_dev = ENV["RACK_ENV"] == "development"
16
+ return true if env_is_dev && @options[:development].nil?
17
+
18
+ @options[:development] == true
19
+ end
20
+
21
+ def api_class
22
+ return Object.const_get(@api_class) if @api_class.is_a?(String) && development?
23
+ return @cached_api ||= Object.const_get(@api_class) if @api_class.is_a?(String)
24
+
25
+ @api_class
26
+ end
27
+
28
+ def base_url
29
+ @options[:base_url] || "https://api.example.com/api/v1"
30
+ end
31
+
32
+ def call(env)
33
+ if @options[:hosts]&.none? { |host| host == env["HTTP_HOST"] }
34
+ return @app.call(env)
35
+ end
36
+
37
+ unless env["PATH_INFO"] == @schema_path
38
+ return @app.call(env)
39
+ end
40
+
41
+ specification = Specification.new(api_class, base_url, @options[:name])
42
+ body = specification.json
43
+
44
+ [200, { "Content-Type" => "application/json", "Content-Length" => body.bytesize.to_s }, [body]]
45
+ end
46
+
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/inflector"
5
+ require "active_support/core_ext"
6
+ require_relative "helpers"
7
+ require_relative "objects"
8
+
9
+ module Apia
10
+ module OpenApi
11
+ class Specification
12
+
13
+ include Apia::OpenApi::Helpers
14
+
15
+ OPEN_API_VERSION = "3.0.0" # The Ruby client generator currently only supports v3.0.0 https://openapi-generator.tech/
16
+
17
+ def initialize(api, base_url, name)
18
+ @api = api
19
+ @base_url = base_url
20
+ @name = name || "Core" # will be suffixed with 'Api' and used in the client generator
21
+ @spec = {
22
+ openapi: OPEN_API_VERSION,
23
+ info: {},
24
+ servers: [],
25
+ paths: {},
26
+ components: {
27
+ schemas: {}
28
+ },
29
+ security: []
30
+ }
31
+ @path_ids = []
32
+ build_spec
33
+ end
34
+
35
+ def json
36
+ JSON.pretty_generate(@spec)
37
+ end
38
+
39
+ private
40
+
41
+ def build_spec
42
+ add_info
43
+ add_servers
44
+ add_paths
45
+ add_security
46
+ end
47
+
48
+ def add_info
49
+ title = @api.definition.name || @api.definition.id
50
+ @spec[:info] = {
51
+ version: "1.0.0",
52
+ title: title
53
+ }
54
+ @spec[:info][:description] = @api.definition.description || "Welcome to the documentation for the #{title}"
55
+ end
56
+
57
+ def add_servers
58
+ @spec[:servers] << { url: @base_url }
59
+ end
60
+
61
+ def add_paths
62
+ @api.definition.route_set.routes.each do |route|
63
+ next unless route.endpoint.definition.schema? # not all routes should be documented
64
+
65
+ Objects::Path.new(
66
+ spec: @spec,
67
+ path_ids: @path_ids,
68
+ route: route,
69
+ name: @name,
70
+ api_authenticator: @api.definition.authenticator
71
+ ).add_to_spec
72
+ end
73
+ end
74
+
75
+ def add_security
76
+ @api.objects.select { |o| o.ancestors.include?(Apia::Authenticator) }.each do |authenticator|
77
+ next unless authenticator.definition.type == :bearer
78
+
79
+ Objects::BearerSecurityScheme.new(spec: @spec, authenticator: authenticator).add_to_spec
80
+ end
81
+ end
82
+
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apia
4
+ module OpenApi
5
+
6
+ VERSION = "0.1.0"
7
+
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "open_api/version"
4
+ require_relative "open_api/rack"
5
+ require_relative "open_api/specification"
metadata ADDED
@@ -0,0 +1,77 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: apia-open_api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Paul Sturgess
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2023-12-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '6'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '6'
27
+ description:
28
+ email:
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - Gemfile
34
+ - LICENSE.txt
35
+ - README.md
36
+ - Rakefile
37
+ - apia-open_api.gemspec
38
+ - lib/apia/open_api.rb
39
+ - lib/apia/open_api/helpers.rb
40
+ - lib/apia/open_api/objects.rb
41
+ - lib/apia/open_api/objects/bearer_security_scheme.rb
42
+ - lib/apia/open_api/objects/parameters.rb
43
+ - lib/apia/open_api/objects/path.rb
44
+ - lib/apia/open_api/objects/request_body.rb
45
+ - lib/apia/open_api/objects/response.rb
46
+ - lib/apia/open_api/objects/schema.rb
47
+ - lib/apia/open_api/rack.rb
48
+ - lib/apia/open_api/specification.rb
49
+ - lib/apia/open_api/version.rb
50
+ homepage: https://github.com/krystal/apia-openapi
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ homepage_uri: https://github.com/krystal/apia-openapi
55
+ source_code_uri: https://github.com/krystal/apia-openapi
56
+ changelog_uri: https://github.com/krystal/apia-openapi/changelog.md
57
+ rubygems_mfa_required: 'false'
58
+ post_install_message:
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: 2.7.0
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.4.10
74
+ signing_key:
75
+ specification_version: 4
76
+ summary: Apia OpenAPI spec generator
77
+ test_files: []