gitlab-grape-openapi 0.7.0 → 0.8.0
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 +4 -4
- data/README.md +38 -0
- data/lib/gitlab/grape_openapi/configuration.rb +2 -1
- data/lib/gitlab/grape_openapi/converters/path_converter.rb +11 -0
- data/lib/gitlab/grape_openapi/converters/response_converter.rb +22 -30
- data/lib/gitlab/grape_openapi/models/response.rb +6 -3
- data/lib/gitlab/grape_openapi/schema_registry.rb +58 -0
- data/lib/gitlab/grape_openapi/version.rb +1 -1
- data/lib/gitlab-grape-openapi.rb +6 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1a821f273e073b35414be3f6c2c7f34e517288e2adb77c7eb55c3076b095b464
|
|
4
|
+
data.tar.gz: 46cdf4d7cbef2541b3b5b4a56402dfb96d1f9d3740a6fcba9122d6b32b7112cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6bf28a6d866c98071dca44a278e52b575c66481a9acefae7c658a9206a8d678f992777bd682b0782fd30f7206de634a2f58f7f5fea45bdc2d72039cba1263422
|
|
7
|
+
data.tar.gz: bcf139d99fcefaeb2a51af83251b431ba5b7bad9eac4df549f7e160e418144dbc6f06dbf4fbe8285c1fa0acd058dbcb1b7d510180cc589d80c71a276bbea59c1
|
data/README.md
CHANGED
|
@@ -30,11 +30,13 @@ Internal gem for generating [OpenAPI 3.0](https://spec.openapis.org/oas/v3.0.0)
|
|
|
30
30
|
- [Parameter constraints](#parameter-constraints)
|
|
31
31
|
- [Registries](#registries)
|
|
32
32
|
- [Response media types](#response-media-types)
|
|
33
|
+
- [Schema-file responses](#schema-file-responses)
|
|
33
34
|
- [Optional path segments](#optional-path-segments)
|
|
34
35
|
- [Operation IDs and request body schemas](#operation-ids-and-request-body-schemas)
|
|
35
36
|
- [Development](#development)
|
|
36
37
|
- [Running Tests](#running-tests)
|
|
37
38
|
- [Linting](#linting)
|
|
39
|
+
- [Specifications](#specifications)
|
|
38
40
|
- [Releasing](#releasing)
|
|
39
41
|
- [Contributing](#contributing)
|
|
40
42
|
- [License](#license)
|
|
@@ -138,6 +140,7 @@ end
|
|
|
138
140
|
| `warnings` | `Boolean` | `false` | Emit stderr warnings for synthesized params, skipped constraints |
|
|
139
141
|
| `cross_field_group_limit` | `Integer` | `30` | Cross-field groups larger than this are summarised on the endpoint |
|
|
140
142
|
| `cross_field_messages` | `Hash` | `{}` | Endpoint wording for summarised groups, keyed by `"METHOD /path"` |
|
|
143
|
+
| `schema_paths` | `Array<String>` | `[]` | Directories searched for the JSON schema files named by `success schema:` |
|
|
141
144
|
|
|
142
145
|
### Annotations
|
|
143
146
|
|
|
@@ -339,6 +342,29 @@ A route that declares `success File` (with no `produces`) is treated as
|
|
|
339
342
|
`application/octet-stream`, matching `grape-swagger`. An explicit `produces`
|
|
340
343
|
takes precedence, so it can override that inference.
|
|
341
344
|
|
|
345
|
+
### Schema-file responses
|
|
346
|
+
|
|
347
|
+
A response that no entity builds (for example one assembled by a proxy) can be
|
|
348
|
+
described by a JSON schema file instead:
|
|
349
|
+
|
|
350
|
+
```ruby
|
|
351
|
+
desc 'Run a query' do
|
|
352
|
+
success code: 200, schema: 'orbit/query_response'
|
|
353
|
+
end
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The name is looked up as `<name>.json` in each directory of `config.schema_paths`
|
|
357
|
+
(first match wins). It may contain lowercase letters, digits, `_` and `/`. The
|
|
358
|
+
file is emitted once under `components/schemas`, named by capitalizing each
|
|
359
|
+
`/`- and `_`-separated part (`orbit/query_response` becomes `OrbitQueryResponse`),
|
|
360
|
+
and referenced from the response. `is_array`, `example`, `examples` and `message`
|
|
361
|
+
work as with `model:`, and `model:` wins when both are given. A missing or
|
|
362
|
+
invalid file, or a component name that is already taken, raises
|
|
363
|
+
`Gitlab::GrapeOpenapi::SchemaFileError`.
|
|
364
|
+
|
|
365
|
+
The file must be an OpenAPI 3.0 Schema Object that JSON Schema validators also
|
|
366
|
+
understand: no `$schema`, `$defs`, type arrays or `nullable`.
|
|
367
|
+
|
|
342
368
|
### Optional path segments
|
|
343
369
|
|
|
344
370
|
Grape lets a route mark a path segment as optional with parentheses, e.g.
|
|
@@ -392,6 +418,18 @@ bundle exec rspec
|
|
|
392
418
|
bundle exec rubocop
|
|
393
419
|
```
|
|
394
420
|
|
|
421
|
+
### Specifications
|
|
422
|
+
|
|
423
|
+
The gem's behaviour is specified with [OpenSpec](https://github.com/Fission-AI/OpenSpec)
|
|
424
|
+
in [`openspec/specs/`](openspec/specs), one capability per directory. Changes to
|
|
425
|
+
generated output start as an OpenSpec change proposal; see `CLAUDE.md` for the
|
|
426
|
+
workflow.
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
npx -y @fission-ai/openspec@1.13.2 list --specs
|
|
430
|
+
npx -y @fission-ai/openspec@1.13.2 validate --all --strict
|
|
431
|
+
```
|
|
432
|
+
|
|
395
433
|
### Releasing
|
|
396
434
|
|
|
397
435
|
Releases are driven by the
|
|
@@ -5,7 +5,7 @@ module Gitlab
|
|
|
5
5
|
class Configuration
|
|
6
6
|
attr_accessor :api_version, :api_prefix, :excluded_api_classes, :servers, :security_schemes, :info,
|
|
7
7
|
:tag_overrides, :annotations, :coercer_mappings, :warnings,
|
|
8
|
-
:cross_field_group_limit, :cross_field_messages
|
|
8
|
+
:cross_field_group_limit, :cross_field_messages, :schema_paths
|
|
9
9
|
|
|
10
10
|
def initialize
|
|
11
11
|
@api_prefix = "api"
|
|
@@ -20,6 +20,7 @@ module Gitlab
|
|
|
20
20
|
@annotations = {}
|
|
21
21
|
@coercer_mappings = {}
|
|
22
22
|
@warnings = false
|
|
23
|
+
@schema_paths = []
|
|
23
24
|
|
|
24
25
|
# Cross field validations
|
|
25
26
|
# Allow custom descriptions for endpoints whose groups of cross-validated
|
|
@@ -81,6 +81,17 @@ module Gitlab
|
|
|
81
81
|
next unless definition
|
|
82
82
|
|
|
83
83
|
EntityConverter.register(definition, @schema_registry)
|
|
84
|
+
register_schema_files(definition)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def register_schema_files(definition)
|
|
89
|
+
case definition
|
|
90
|
+
when Hash
|
|
91
|
+
return if EntityConverter.grape_entity?(definition[:model])
|
|
92
|
+
|
|
93
|
+
@schema_registry.register_file(definition[:schema]) if definition[:schema]
|
|
94
|
+
when Array then definition.each { |item| register_schema_files(item) }
|
|
84
95
|
end
|
|
85
96
|
end
|
|
86
97
|
|
|
@@ -63,14 +63,14 @@ module Gitlab
|
|
|
63
63
|
|
|
64
64
|
def process_hash_entity(entity_hash)
|
|
65
65
|
if entity_hash[:model] && EntityConverter.grape_entity?(entity_hash[:model])
|
|
66
|
-
status_code = entity_hash[:code] || infer_success_code(with_body: true)
|
|
67
66
|
add_response_with_entity(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
**hash_response(entity_hash),
|
|
68
|
+
entity_class: entity_hash[:model]
|
|
69
|
+
)
|
|
70
|
+
elsif entity_hash[:schema]
|
|
71
|
+
add_response_with_entity(
|
|
72
|
+
**hash_response(entity_hash),
|
|
73
|
+
schema_name: SchemaRegistry.file_component_name(entity_hash[:schema])
|
|
74
74
|
)
|
|
75
75
|
else
|
|
76
76
|
status_code = entity_hash[:code] || infer_success_code(with_body: declared_body?)
|
|
@@ -82,38 +82,28 @@ module Gitlab
|
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
def hash_response(entity_hash)
|
|
86
|
+
status_code = entity_hash[:code] || infer_success_code(with_body: true)
|
|
87
|
+
{
|
|
88
|
+
status_code: status_code,
|
|
89
|
+
description: entity_hash[:message] || http_status_text(status_code),
|
|
90
|
+
example: entity_hash[:example],
|
|
91
|
+
examples: entity_hash[:examples],
|
|
92
|
+
is_array: entity_hash.fetch(:is_array, declared_is_array?)
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
85
96
|
def process_array_entities(entity_array)
|
|
86
97
|
entity_array.each do |definition|
|
|
87
98
|
case definition
|
|
88
99
|
when Hash
|
|
89
|
-
|
|
100
|
+
process_hash_entity(definition)
|
|
90
101
|
when Class
|
|
91
102
|
process_class_entity(definition)
|
|
92
103
|
end
|
|
93
104
|
end
|
|
94
105
|
end
|
|
95
106
|
|
|
96
|
-
def process_array_hash_item(definition)
|
|
97
|
-
if definition[:model] && EntityConverter.grape_entity?(definition[:model])
|
|
98
|
-
status_code = definition[:code] || infer_success_code(with_body: true)
|
|
99
|
-
add_response_with_entity(
|
|
100
|
-
status_code: status_code,
|
|
101
|
-
description: definition[:message] || http_status_text(status_code),
|
|
102
|
-
entity_class: definition[:model],
|
|
103
|
-
example: definition[:example],
|
|
104
|
-
examples: definition[:examples],
|
|
105
|
-
is_array: definition.fetch(:is_array, declared_is_array?)
|
|
106
|
-
)
|
|
107
|
-
else
|
|
108
|
-
status_code = definition[:code] || infer_success_code(with_body: declared_body?)
|
|
109
|
-
add_simple_response(
|
|
110
|
-
status_code: status_code,
|
|
111
|
-
description: definition[:message] || http_status_text(status_code),
|
|
112
|
-
content: declared_content
|
|
113
|
-
)
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
107
|
def extract_failure_responses
|
|
118
108
|
explicit_codes = Array(@route.http_codes)
|
|
119
109
|
defined_status_codes = explicit_codes.filter_map { |c| (c.is_a?(Hash) ? c[:code] : c[0])&.to_i }
|
|
@@ -136,11 +126,13 @@ module Gitlab
|
|
|
136
126
|
end
|
|
137
127
|
|
|
138
128
|
def add_response_with_entity(
|
|
139
|
-
status_code:, description:, entity_class
|
|
129
|
+
status_code:, description:, entity_class: nil, schema_name: nil, example: nil, examples: nil,
|
|
130
|
+
is_array: false)
|
|
140
131
|
response = Models::Response.new(
|
|
141
132
|
status_code: status_code,
|
|
142
133
|
description: description,
|
|
143
134
|
entity_class: entity_class,
|
|
135
|
+
schema_name: schema_name,
|
|
144
136
|
example: example,
|
|
145
137
|
examples: examples,
|
|
146
138
|
is_array: is_array
|
|
@@ -7,18 +7,21 @@ module Gitlab
|
|
|
7
7
|
class Response
|
|
8
8
|
attr_reader :status_code, :description, :entity_class, :headers, :content_type
|
|
9
9
|
|
|
10
|
+
# rubocop:disable Metrics/ParameterLists -- keyword arguments of a value object
|
|
10
11
|
def initialize(
|
|
11
|
-
status_code:, description:, entity_class
|
|
12
|
-
example: nil, examples: nil, is_array: false)
|
|
12
|
+
status_code:, description:, entity_class: nil, schema_name: nil, headers: {},
|
|
13
|
+
content_type: 'application/json', example: nil, examples: nil, is_array: false)
|
|
13
14
|
@status_code = status_code.to_s
|
|
14
15
|
@description = description
|
|
15
16
|
@entity_class = entity_class
|
|
17
|
+
@schema_name = schema_name
|
|
16
18
|
@headers = headers
|
|
17
19
|
@content_type = content_type
|
|
18
20
|
@example = example
|
|
19
21
|
@examples = examples
|
|
20
22
|
@is_array = is_array
|
|
21
23
|
end
|
|
24
|
+
# rubocop:enable Metrics/ParameterLists
|
|
22
25
|
|
|
23
26
|
def to_h(schema_registry)
|
|
24
27
|
response = {
|
|
@@ -49,7 +52,7 @@ module Gitlab
|
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def schema_ref(schema_registry)
|
|
52
|
-
normalized_name = schema_registry.register(entity_class, nil)
|
|
55
|
+
normalized_name = @schema_name || schema_registry.register(entity_class, nil)
|
|
53
56
|
"#/components/schemas/#{normalized_name}"
|
|
54
57
|
end
|
|
55
58
|
end
|
|
@@ -5,12 +5,42 @@ module Gitlab
|
|
|
5
5
|
class SchemaRegistry
|
|
6
6
|
attr_reader :schemas
|
|
7
7
|
|
|
8
|
+
SCHEMA_FILE_NAME = %r{\A[a-z0-9_]+(/[a-z0-9_]+)*\z}
|
|
9
|
+
|
|
10
|
+
def self.file_component_name(name)
|
|
11
|
+
name.to_s.split(%r{[/_]}).map(&:capitalize).join
|
|
12
|
+
end
|
|
13
|
+
|
|
8
14
|
def initialize
|
|
9
15
|
@schemas = {}
|
|
16
|
+
@file_paths = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Registers the `<name>.json` schema found in `config.schema_paths` and
|
|
20
|
+
# returns its component name.
|
|
21
|
+
def register_file(name)
|
|
22
|
+
unless SCHEMA_FILE_NAME.match?(name.to_s)
|
|
23
|
+
raise SchemaFileError, "Invalid schema name #{name.inspect}: " \
|
|
24
|
+
"use lowercase letters, digits and underscores, with '/' between segments"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
path = find_schema_file(name)
|
|
28
|
+
component = self.class.file_component_name(name)
|
|
29
|
+
return component if @file_paths[component] == path
|
|
30
|
+
|
|
31
|
+
raise SchemaFileError, "Schema #{name.inspect} collides with component #{component}" if @schemas.key?(component)
|
|
32
|
+
|
|
33
|
+
@schemas[component] = deep_freeze(parse_schema_file(path))
|
|
34
|
+
@file_paths[component] = path
|
|
35
|
+
component
|
|
10
36
|
end
|
|
11
37
|
|
|
12
38
|
def register(entity_class, schema)
|
|
13
39
|
normalized_name = normalize_entity_class(entity_class)
|
|
40
|
+
if @file_paths.key?(normalized_name)
|
|
41
|
+
raise SchemaFileError, "Entity #{entity_class.name} collides with schema file component #{normalized_name}"
|
|
42
|
+
end
|
|
43
|
+
|
|
14
44
|
return normalized_name if @schemas.key?(normalized_name)
|
|
15
45
|
return normalized_name unless schema.is_a?(Models::Schema)
|
|
16
46
|
|
|
@@ -21,6 +51,34 @@ module Gitlab
|
|
|
21
51
|
def normalize_entity_class(entity_class)
|
|
22
52
|
entity_class.name.delete(':')
|
|
23
53
|
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def find_schema_file(name)
|
|
58
|
+
dirs = Gitlab::GrapeOpenapi.configuration.schema_paths
|
|
59
|
+
path = dirs.map { |dir| File.join(dir, "#{name}.json") }.find { |candidate| File.file?(candidate) }
|
|
60
|
+
return path if path
|
|
61
|
+
|
|
62
|
+
searched = dirs.empty? ? 'config.schema_paths is empty' : "searched #{dirs.join(', ')}"
|
|
63
|
+
raise SchemaFileError, "Schema file #{name.inspect} not found: #{searched}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_schema_file(path)
|
|
67
|
+
schema = JSON.parse(File.read(path))
|
|
68
|
+
return schema if schema.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
raise SchemaFileError, "Schema file #{path} must contain a JSON object"
|
|
71
|
+
rescue JSON::ParserError => e
|
|
72
|
+
raise SchemaFileError, "Schema file #{path} is not valid JSON: #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def deep_freeze(value)
|
|
76
|
+
case value
|
|
77
|
+
when Hash then value.each_value { |item| deep_freeze(item) }
|
|
78
|
+
when Array then value.each { |item| deep_freeze(item) }
|
|
79
|
+
end
|
|
80
|
+
value.freeze
|
|
81
|
+
end
|
|
24
82
|
end
|
|
25
83
|
end
|
|
26
84
|
end
|
data/lib/gitlab-grape-openapi.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
require_relative "gitlab/grape_openapi/version"
|
|
4
6
|
require_relative "gitlab/grape_openapi/grape_compat"
|
|
5
7
|
require_relative "gitlab/grape_openapi/configuration"
|
|
@@ -59,6 +61,10 @@ module Gitlab
|
|
|
59
61
|
# containing a named parameter (e.g. `/foo(/:a)/bar(/:b)`).
|
|
60
62
|
MultipleOptionalSegmentsError = Class.new(GenerationError)
|
|
61
63
|
|
|
64
|
+
# Raised when a schema file named by a `success` declaration cannot be
|
|
65
|
+
# found, parsed or registered.
|
|
66
|
+
SchemaFileError = Class.new(GenerationError)
|
|
67
|
+
|
|
62
68
|
class << self
|
|
63
69
|
attr_writer :configuration
|
|
64
70
|
|