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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b864030f21b5380efa37ccb02b953f5c8d07b2f4d2fe90eb66334e155674e2bc
4
- data.tar.gz: 37b0f04c277c0b1f1027bdb5f0839f06641a3c6e11fd2e9498cb9a192899914b
3
+ metadata.gz: 1a821f273e073b35414be3f6c2c7f34e517288e2adb77c7eb55c3076b095b464
4
+ data.tar.gz: 46cdf4d7cbef2541b3b5b4a56402dfb96d1f9d3740a6fcba9122d6b32b7112cf
5
5
  SHA512:
6
- metadata.gz: d7e270e89449c2f1dbdfa2f6acb4bb841abf7bbb25adf064a3f41fbf2a61eb8e9f86f698d8c826cdb111cf19aa6f21269984453913fc53554ce6f7f8eb502ba8
7
- data.tar.gz: a6e772a0f79d6d1e010ddddf7a6504a96da9d94c2ad34db08bbf58d80e7bffd8df0364dc09b361c0da5cce3d2b163a2dc24cb5d57604a543926a2c1ddc4ed5fa
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
- status_code: status_code,
69
- description: entity_hash[:message] || http_status_text(status_code),
70
- entity_class: entity_hash[:model],
71
- example: entity_hash[:example],
72
- examples: entity_hash[:examples],
73
- is_array: entity_hash.fetch(:is_array, declared_is_array?)
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
- process_array_hash_item(definition)
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:, example: nil, examples: nil, is_array: false)
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:, headers: {}, content_type: 'application/json',
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Gitlab
4
4
  module GrapeOpenapi
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
@@ -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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-grape-openapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - group::api