jbuilder-schema 1.0.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: 1a468c68f0ab10f9d02f4b38e5b42ecfaa7a9570d8cc95e4f1a586bcdcd4b8d8
4
+ data.tar.gz: 9cfbdbc84f0411af2945277c534ad86d3167bc276da4055630a75b9aab56f668
5
+ SHA512:
6
+ metadata.gz: 5a45615f7ff8b2733fb20a21057ac66b46c8efcb40149dbf54e61d5aafc9d343e8977d3569806e1f5e5d17207db7378941c2b1f8199598faa8fe212886057320
7
+ data.tar.gz: 516e22d82465a265605237ed8e777ebdfa4a39e6bc3edf49ad2f7bc83d26ad5129a55edc9f4c616c40f49c33ded8cbc0134a192fcb996fd0b1462692106321ab
data/.standard.yml ADDED
@@ -0,0 +1,5 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/testdouble/standard
3
+ ruby_version: 3.0
4
+ fix: true
5
+ parallel: true
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in jbuilder-schema.gemspec
6
+ gemspec
7
+
8
+ gem "rake", "~> 13.0"
9
+
10
+ gem "factory_bot"
11
+ gem "faker"
12
+
13
+ gem "standard", "~> 1.3"
14
+
15
+ gem "mocha"
data/Gemfile.lock ADDED
@@ -0,0 +1,200 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ jbuilder-schema (1.0.0)
5
+ jbuilder
6
+ rails (>= 5.0.0)
7
+ safe_parser
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ actioncable (7.0.3.1)
13
+ actionpack (= 7.0.3.1)
14
+ activesupport (= 7.0.3.1)
15
+ nio4r (~> 2.0)
16
+ websocket-driver (>= 0.6.1)
17
+ actionmailbox (7.0.3.1)
18
+ actionpack (= 7.0.3.1)
19
+ activejob (= 7.0.3.1)
20
+ activerecord (= 7.0.3.1)
21
+ activestorage (= 7.0.3.1)
22
+ activesupport (= 7.0.3.1)
23
+ mail (>= 2.7.1)
24
+ net-imap
25
+ net-pop
26
+ net-smtp
27
+ actionmailer (7.0.3.1)
28
+ actionpack (= 7.0.3.1)
29
+ actionview (= 7.0.3.1)
30
+ activejob (= 7.0.3.1)
31
+ activesupport (= 7.0.3.1)
32
+ mail (~> 2.5, >= 2.5.4)
33
+ net-imap
34
+ net-pop
35
+ net-smtp
36
+ rails-dom-testing (~> 2.0)
37
+ actionpack (7.0.3.1)
38
+ actionview (= 7.0.3.1)
39
+ activesupport (= 7.0.3.1)
40
+ rack (~> 2.0, >= 2.2.0)
41
+ rack-test (>= 0.6.3)
42
+ rails-dom-testing (~> 2.0)
43
+ rails-html-sanitizer (~> 1.0, >= 1.2.0)
44
+ actiontext (7.0.3.1)
45
+ actionpack (= 7.0.3.1)
46
+ activerecord (= 7.0.3.1)
47
+ activestorage (= 7.0.3.1)
48
+ activesupport (= 7.0.3.1)
49
+ globalid (>= 0.6.0)
50
+ nokogiri (>= 1.8.5)
51
+ actionview (7.0.3.1)
52
+ activesupport (= 7.0.3.1)
53
+ builder (~> 3.1)
54
+ erubi (~> 1.4)
55
+ rails-dom-testing (~> 2.0)
56
+ rails-html-sanitizer (~> 1.1, >= 1.2.0)
57
+ activejob (7.0.3.1)
58
+ activesupport (= 7.0.3.1)
59
+ globalid (>= 0.3.6)
60
+ activemodel (7.0.3.1)
61
+ activesupport (= 7.0.3.1)
62
+ activerecord (7.0.3.1)
63
+ activemodel (= 7.0.3.1)
64
+ activesupport (= 7.0.3.1)
65
+ activestorage (7.0.3.1)
66
+ actionpack (= 7.0.3.1)
67
+ activejob (= 7.0.3.1)
68
+ activerecord (= 7.0.3.1)
69
+ activesupport (= 7.0.3.1)
70
+ marcel (~> 1.0)
71
+ mini_mime (>= 1.1.0)
72
+ activesupport (7.0.3.1)
73
+ concurrent-ruby (~> 1.0, >= 1.0.2)
74
+ i18n (>= 1.6, < 2)
75
+ minitest (>= 5.1)
76
+ tzinfo (~> 2.0)
77
+ ast (2.4.2)
78
+ builder (3.2.4)
79
+ concurrent-ruby (1.1.10)
80
+ crass (1.0.6)
81
+ erubi (1.11.0)
82
+ factory_bot (6.2.1)
83
+ activesupport (>= 5.0.0)
84
+ faker (2.22.0)
85
+ i18n (>= 1.8.11, < 2)
86
+ globalid (1.0.0)
87
+ activesupport (>= 5.0)
88
+ i18n (1.12.0)
89
+ concurrent-ruby (~> 1.0)
90
+ jbuilder (2.11.5)
91
+ actionview (>= 5.0.0)
92
+ activesupport (>= 5.0.0)
93
+ json (2.6.2)
94
+ loofah (2.19.0)
95
+ crass (~> 1.0.2)
96
+ nokogiri (>= 1.5.9)
97
+ mail (2.7.1)
98
+ mini_mime (>= 0.1.1)
99
+ marcel (1.0.2)
100
+ method_source (1.0.0)
101
+ mini_mime (1.1.2)
102
+ mini_portile2 (2.8.0)
103
+ minitest (5.16.2)
104
+ mocha (1.14.0)
105
+ net-imap (0.3.1)
106
+ net-protocol
107
+ net-pop (0.1.2)
108
+ net-protocol
109
+ net-protocol (0.1.3)
110
+ timeout
111
+ net-smtp (0.3.2)
112
+ net-protocol
113
+ nio4r (2.5.8)
114
+ nokogiri (1.13.8)
115
+ mini_portile2 (~> 2.8.0)
116
+ racc (~> 1.4)
117
+ parallel (1.22.1)
118
+ parser (3.1.2.0)
119
+ ast (~> 2.4.1)
120
+ racc (1.6.0)
121
+ rack (2.2.4)
122
+ rack-test (2.0.2)
123
+ rack (>= 1.3)
124
+ rails (7.0.3.1)
125
+ actioncable (= 7.0.3.1)
126
+ actionmailbox (= 7.0.3.1)
127
+ actionmailer (= 7.0.3.1)
128
+ actionpack (= 7.0.3.1)
129
+ actiontext (= 7.0.3.1)
130
+ actionview (= 7.0.3.1)
131
+ activejob (= 7.0.3.1)
132
+ activemodel (= 7.0.3.1)
133
+ activerecord (= 7.0.3.1)
134
+ activestorage (= 7.0.3.1)
135
+ activesupport (= 7.0.3.1)
136
+ bundler (>= 1.15.0)
137
+ railties (= 7.0.3.1)
138
+ rails-dom-testing (2.0.3)
139
+ activesupport (>= 4.2.0)
140
+ nokogiri (>= 1.6)
141
+ rails-html-sanitizer (1.4.3)
142
+ loofah (~> 2.3)
143
+ railties (7.0.3.1)
144
+ actionpack (= 7.0.3.1)
145
+ activesupport (= 7.0.3.1)
146
+ method_source
147
+ rake (>= 12.2)
148
+ thor (~> 1.0)
149
+ zeitwerk (~> 2.5)
150
+ rainbow (3.1.1)
151
+ rake (13.0.6)
152
+ regexp_parser (2.5.0)
153
+ rexml (3.2.5)
154
+ rubocop (1.32.0)
155
+ json (~> 2.3)
156
+ parallel (~> 1.10)
157
+ parser (>= 3.1.0.0)
158
+ rainbow (>= 2.2.2, < 4.0)
159
+ regexp_parser (>= 1.8, < 3.0)
160
+ rexml (>= 3.2.5, < 4.0)
161
+ rubocop-ast (>= 1.19.1, < 2.0)
162
+ ruby-progressbar (~> 1.7)
163
+ unicode-display_width (>= 1.4.0, < 3.0)
164
+ rubocop-ast (1.19.1)
165
+ parser (>= 3.1.1.0)
166
+ rubocop-performance (1.14.3)
167
+ rubocop (>= 1.7.0, < 2.0)
168
+ rubocop-ast (>= 0.4.0)
169
+ ruby-progressbar (1.11.0)
170
+ ruby_parser (3.8.4)
171
+ sexp_processor (~> 4.1)
172
+ safe_parser (1.0.0)
173
+ ruby_parser (~> 3.8.4)
174
+ sexp_processor (4.16.1)
175
+ standard (1.14.0)
176
+ rubocop (= 1.32.0)
177
+ rubocop-performance (= 1.14.3)
178
+ thor (1.2.1)
179
+ timeout (0.3.0)
180
+ tzinfo (2.0.5)
181
+ concurrent-ruby (~> 1.0)
182
+ unicode-display_width (2.2.0)
183
+ websocket-driver (0.7.5)
184
+ websocket-extensions (>= 0.1.0)
185
+ websocket-extensions (0.1.5)
186
+ zeitwerk (2.6.1)
187
+
188
+ PLATFORMS
189
+ ruby
190
+
191
+ DEPENDENCIES
192
+ factory_bot
193
+ faker
194
+ jbuilder-schema!
195
+ mocha
196
+ rake (~> 13.0)
197
+ standard (~> 1.3)
198
+
199
+ BUNDLED WITH
200
+ 2.3.16
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 Yuri Sidorov
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,234 @@
1
+ # JbuilderSchema
2
+
3
+ Generate JSON Schema compatible with OpenAPI 3 specs from Jbuilder files
4
+
5
+ [![Tests](https://github.com/bullet-train-co/jbuilder-schema/actions/workflows/tests.yml/badge.svg)](https://github.com/bullet-train-co/jbuilder-schema/actions)
6
+ [![Standard](https://github.com/bullet-train-co/jbuilder-schema/actions/workflows/standard.yml/badge.svg)](https://github.com/bullet-train-co/jbuilder-schema/actions)
7
+
8
+ ## Installation
9
+
10
+ In Gemfile put `gem "jbuilder-schema"` **before** `gem "jbuilder"`:
11
+
12
+ gem "jbuilder-schema", require: "jbuilder/schema"
13
+ gem "jbuilder"
14
+
15
+ And then execute:
16
+
17
+ $ bundle
18
+
19
+ Or install it yourself as:
20
+
21
+ $ gem install jbuilder-schema
22
+
23
+ ## Usage
24
+
25
+ Wherever you want to generate schemas, you should extend `JbuilderSchema`:
26
+
27
+ extend JbuilderSchema
28
+
29
+ Then you can use `jbuilder_schema` helper:
30
+
31
+ jbuilder_schema('api/v1/articles/_article',
32
+ title: 'Article',
33
+ description: 'Article in the blog',
34
+ format: :yaml,
35
+ paths: view_paths.map(&:path),
36
+ model: Article,
37
+ locals: {
38
+ article: @article,
39
+ current_user: @user
40
+ })
41
+
42
+ `jbuilder_schema` helper takes `path` to Jbuilder template as a first argument and several optional arguments:
43
+
44
+ - `title` and `description`: Title and description of schema, if not passed then they will be grabbed from locale files (see *[Titles & Descriptions](#titles--descriptions)*);
45
+ - `format`: Desired output format, can be either `:yaml` or `:json`. If no `format` option is passed, the output will be the Ruby Hash object;
46
+ - `paths`: If you need to scope any other paths than `app/views`, pass them as an array here;
47
+ - `model`: Model described in template, this is needed to populate `required` field in schema;
48
+ - `locals`: Here you should pass all the locals which are met in the jbuilder template. Those could be any objects as long as they respond to methods called on them in template.
49
+
50
+ Notice that partial templates should be prepended with an underscore just like in the name of the file (i.e. `_article` but not `article` an when using Jbuilder).
51
+
52
+ ### Output
53
+
54
+ JbuilderSchema automatically sets `description`, `type`, and `required` options in JSON-Schema.
55
+
56
+ For example, if we have `_articles.json.jbilder` file:
57
+
58
+ json.extract! article, :id, :title, :body, :created_at
59
+
60
+ The output for it will be:
61
+
62
+ type: object
63
+ title: Article
64
+ description: Article in the blog
65
+ required:
66
+ - id
67
+ - title
68
+ - body
69
+ properties:
70
+ id:
71
+ description: ID of an article
72
+ type: integer
73
+ title:
74
+ description: Title of an article
75
+ type: string
76
+ body:
77
+ description: Contents of an article
78
+ type: string
79
+ created_at:
80
+ description: Timestamp when article was created
81
+ type: string
82
+ format: date-time
83
+
84
+ ### Customization
85
+
86
+ #### Simple
87
+
88
+ Sometimes you would want to set you own data in generated JSON-Schema. All you need to do is just pass hash with it under `schema` keyword in your jbuilder template:
89
+
90
+ json.id article.id, schema: { type: :number, description: "Custom ID description" }
91
+ json.title article.title, schema: { minLength: 5, maxLength: 20 }
92
+ json.body article.body, schema: { type: :text, maxLength: 500 }
93
+ json.created_at article.created_at.strftime('%d/%m/%Y'), schema: { format: :date, pattern: /^(3[01]|[12][0-9]|0[1-9])\/(1[0-2]|0[1-9])\/[0-9]{4}$/ }
94
+
95
+ This will produce the following:
96
+
97
+ ...
98
+ properties:
99
+ id:
100
+ description: Custom ID description
101
+ type: number
102
+ title:
103
+ description: Title of an article
104
+ type: string
105
+ minLength: 5
106
+ maxLength: 20
107
+ body:
108
+ description: Contents of an article
109
+ type: string
110
+ maxLength: 500
111
+ created_at:
112
+ description: Timestamp when article was created
113
+ type: string
114
+ format: date
115
+ pattern: "^(3[01]|[12][0-9]|0[1-9])\/(1[0-2]|0[1-9])\/[0-9]{4}$"
116
+
117
+ #### Bulk
118
+
119
+ You can customize output for multiple fields at once:
120
+
121
+ json.extract! user, :id, :name, :email, schema: {id: {type: :string}, email: {type: :email, pattern: /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/}}
122
+
123
+ ### Nested objects
124
+
125
+ When you have nested objects in jbuilder template, you have to pass it to `schema: {object: <nested_object>}` when the block starts:
126
+
127
+ json.extract! article
128
+ json.author schema: {object: article.user, object_title: "Author", object_description: "Authors are users who write articles"} do
129
+ json.extract! article.user
130
+ end
131
+
132
+ This will help JbuilderSchema to process those fields right.
133
+
134
+ ### Collections
135
+
136
+ If an object or an array of objects is generated in template, either in root or in some field through Jbuilder partials, JSON-Schema `$ref` is generated pointing to object with the same name as partial. By default those schemas should appear in `"#/components/schemas/"`.
137
+
138
+ For example, if we have:
139
+
140
+ json.user do
141
+ json.partial! 'api/v1/users/user', user: user
142
+ end
143
+
144
+ json.articles do
145
+ json.array! user.articles, partial: "api/v1/articles/article", as: :article
146
+ end
147
+
148
+ The result would be:
149
+
150
+ user:
151
+ type: object
152
+ $ref: #/components/schemas/user
153
+ articles:
154
+ type: array
155
+ items:
156
+ $ref: #/components/schemas/article
157
+
158
+ The path to component schemas can be configured with `components_path` variable, which defaults to `components/schemas`. See *[Configuration](#configuration)* for more info.
159
+
160
+ ### Titles & Descriptions
161
+
162
+ Custom titles and descriptions for objects can be specified when calling `jbuilder-schema` helper (see *[Usage](#usage)*), for fields and nested objects within `schema` attributes (see *[Customization](#simple)* and *[Nested objects](#nested-objects)*). If not set, they will be searched in locale files.
163
+
164
+ Titles and descriptions for the models are supposed to be found in locale files under `<underscored_plural_model_name>.<title_name>` and `<underscored_plural_model_name>.<description_name>`, for example:
165
+
166
+ en:
167
+ articles:
168
+ title: Article
169
+ description: The main object on the blog
170
+
171
+ Descriptions for the fields are supposed to be found in locale files under `<underscored_plural_model_name>.fields.<field_name>.<description_name>`, for example:
172
+
173
+ en:
174
+ articles:
175
+ fields:
176
+ title:
177
+ description: The title of an article
178
+
179
+ `<title_name>` and `<description_name>` can be configured (see *[Configuration](#configuration)*), it defaults to `title` and `description`.
180
+
181
+ ### Configuration
182
+
183
+ You can configure some variables that JbuilderSchema uses (for example, in `config/initializers/jbuilder_schema.rb`):
184
+
185
+ JbuilderSchema.configure do |config|
186
+ config.components_path = "components/schemas" # could be "definitions/schemas"
187
+ config.title_name = "title" # could be "label"
188
+ config.description_name = "description" # could be "heading"
189
+ end
190
+
191
+ ### RSwag
192
+
193
+ It's super easy to use JbuilderSchema with RSwag: just add `jbuilder_schema` helper in `swagger_helper.rb` like this:
194
+
195
+ RSpec.configure do |config|
196
+ extend JbuilderSchema
197
+
198
+ ...
199
+
200
+ config.swagger_docs = {
201
+
202
+ ...
203
+
204
+ components: {
205
+ schemas: {
206
+ article: jbuilder_schema('api/v1/articles/_article',
207
+ format: :yaml,
208
+ model: Article,
209
+ title: 'Article',
210
+ description: 'Article in the blog',
211
+ locals: {
212
+ article: FactoryBot.build(:article, id: 1),
213
+ current_user: FactoryBot.build(:user, admin: true)
214
+ })
215
+ }
216
+ }
217
+
218
+ ...
219
+
220
+ }
221
+
222
+ ...
223
+
224
+ ## Contributing
225
+
226
+ Bug reports and pull requests are welcome on GitHub at https://github.com/bullet-train-co/jbuilder-schema.
227
+
228
+ ## License
229
+
230
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
231
+
232
+ ## Open-source development sponsored by:
233
+
234
+ <a href="https://www.clickfunnels.com"><img src="https://images.clickfunnel.com/uploads/digital_asset/file/176632/clickfunnels-dark-logo.svg" width="575" /></a>
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+ require "standard/rake"
6
+
7
+ Rake::TestTask.new do |t|
8
+ t.libs << "test"
9
+ t.test_files = FileList["test/**/*_test.rb"]
10
+ t.verbose = true
11
+ t.warning = false
12
+ end
13
+
14
+ task default: %i[test standard]
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jbuilder"
4
+
5
+ # Patches for Jbuilder to make it ignore schema metadata
6
+ class Jbuilder
7
+ alias_method :original_method_missing, :method_missing
8
+
9
+ def method_missing(*args, &block)
10
+ args = _extract_schema_meta!(*args)
11
+ original_method_missing(*args, &block)
12
+ end
13
+
14
+ def respond_to_missing?(method_name, include_private = false)
15
+ super
16
+ end
17
+
18
+ private
19
+
20
+ def _extract_schema_meta!(*args)
21
+ args.delete_if { |a| a.is_a?(::Hash) && a.key?(:schema) }
22
+ end
23
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jbuilder/schema/resolver"
4
+ require "jbuilder/schema/renderer"
5
+
6
+ module JbuilderSchema
7
+ # Class that builds schema object from path
8
+ class Builder
9
+ attr_reader :path, :template, :model, :locals, :format, :paths
10
+
11
+ def initialize(path, **options)
12
+ @path = path
13
+ # TODO: Need this for `required`, make it simpler:
14
+ @model = options[:model]
15
+ @locals = options[:locals] || {}
16
+ @format = options[:format]
17
+ @paths = options[:paths] || ["app/views"]
18
+ @template = _render_template(**options)
19
+ end
20
+
21
+ def schema!
22
+ return {} unless template
23
+
24
+ case format
25
+ when :yaml
26
+ _yaml_schema
27
+ when :json
28
+ _json_schema
29
+ else
30
+ _schema
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def _schema
37
+ template.schema!
38
+ end
39
+
40
+ def _stringified_schema
41
+ _schema.deep_stringify_keys
42
+ .deep_transform_values { |v| v.is_a?(Symbol) ? v.to_s : v }
43
+ .deep_transform_values { |v| v.is_a?(Regexp) ? v.source : v }
44
+ end
45
+
46
+ def _yaml_schema
47
+ YAML.dump(_stringified_schema).html_safe
48
+ end
49
+
50
+ def _json_schema
51
+ JSON.dump(_stringified_schema).html_safe
52
+ end
53
+
54
+ def _find_template
55
+ prefix, controller, action, partial = _resolve_path
56
+ found = nil
57
+ paths.each do |path|
58
+ found = Resolver.new("#{path}/#{prefix}").find_all(action, controller, partial)
59
+ break if found
60
+ end
61
+ found
62
+ end
63
+
64
+ def _resolve_path
65
+ action = path.split("/").last
66
+ controller = path.split("/")[-2]
67
+ prefix = path.delete_suffix("/#{controller}/#{action}")
68
+ partial = action[0] == "_"
69
+
70
+ action.delete_prefix!("_") if action[0] == "_"
71
+
72
+ [prefix, controller, action, partial]
73
+ end
74
+
75
+ def _render_template(**options)
76
+ Renderer.new(**options).render(_find_template)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Configuration
4
+ module JbuilderSchema
5
+ class << self
6
+ def configuration
7
+ @configuration ||= Configuration.new
8
+ end
9
+
10
+ def reset
11
+ @configuration = Configuration.new
12
+ end
13
+
14
+ def configure
15
+ yield(configuration)
16
+ end
17
+ end
18
+
19
+ # Configuration class with defaults
20
+ class Configuration
21
+ attr_accessor :components_path, :title_name, :description_name
22
+
23
+ def initialize
24
+ @components_path = "components/schemas"
25
+ @title_name = "title"
26
+ @description_name = "description"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jbuilder/schema/template"
4
+ # TODO: Find a better way to load main app's helpers:
5
+ # Helpers don't work in Jbuilder itself, so no need to include them here!
6
+ # ActionController::Base.all_helpers_from_path('app/helpers').each { |helper| require "./app/helpers/#{helper}_helper" }
7
+
8
+ module JbuilderSchema
9
+ # Here we initialize all the variables needed for template and pass them to it
10
+ class Renderer
11
+ # TODO: Find a better way to load main app's helpers:
12
+ # Helpers don't work in Jbuilder itself, so no need to include them here!
13
+ # ActionController::Base.all_helpers_from_path('app/helpers').each { |helper| include Object.const_get("::#{helper.camelize}Helper") }
14
+
15
+ attr_reader :locals, :options
16
+
17
+ def initialize(**options)
18
+ @locals = options.delete(:locals) || {}
19
+ @options = options
20
+ _define_locals!
21
+ end
22
+
23
+ def render(source)
24
+ Template.new(**options) do |json|
25
+ # TODO: Get rid of 'eval'
26
+ eval source.to_s # standard:disable Security/Eval
27
+ end
28
+ end
29
+
30
+ def method_missing(method, *args)
31
+ if method.to_s.end_with?("_path", "_url")
32
+ method.to_s
33
+ else
34
+ super
35
+ end
36
+ end
37
+
38
+ def respond_to_missing?(method_name, include_private = false)
39
+ method_name.to_s.end_with?("_path", "_url") || super
40
+ end
41
+
42
+ private
43
+
44
+ def _define_locals!
45
+ locals.each do |k, v|
46
+ # Setting instance variables (`@article`):
47
+ instance_variable_set("@#{k}", v)
48
+
49
+ # Setting local variables (`article`):
50
+ # We can define method:
51
+ # define_singleton_method(k) { v }
52
+ # or set attr_reader on an instance, this feels better:
53
+ singleton_class.instance_eval { attr_reader k }
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jbuilder/schema/template"
4
+
5
+ module JbuilderSchema
6
+ # Resolver finds and returns Jbuilder template.
7
+ # It basically inherits from ActionView::FileSystemResolver as it does all the job for us.
8
+ # We're just building our own template in the end of the search.
9
+ class Resolver < ::ActionView::FileSystemResolver
10
+ attr_reader :template
11
+
12
+ def find_all(name, prefix = nil, partial = false)
13
+ _find_all(name, prefix, partial)
14
+ end
15
+
16
+ private
17
+
18
+ def _find_all(name, prefix, partial)
19
+ path = ActionView::TemplatePath.build(name, prefix, partial)
20
+ templates_from_path(path).first
21
+ end
22
+
23
+ def templates_from_path(path)
24
+ return [] if path.name.include?(".")
25
+
26
+ # Instead of checking for every possible path, as our other globs would
27
+ # do, scan the directory for files with the right prefix.
28
+ paths = template_glob("#{escape_entry(path.to_s)}*")
29
+
30
+ paths.map do |p|
31
+ _source(p)
32
+ end
33
+ end
34
+
35
+ def _source(template)
36
+ source_for_template(template)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jbuilder/jbuilder_template"
4
+ require "active_support/inflections"
5
+ require "active_support/core_ext/hash/deep_transform_values"
6
+ require "safe_parser"
7
+
8
+ module JbuilderSchema
9
+ # Template parser class
10
+ class Template < ::JbuilderTemplate
11
+ attr_reader :attributes, :type, :models, :titles, :descriptions
12
+
13
+ def initialize(*args, **options)
14
+ @type = :object
15
+ @inline_array = false
16
+ @collection = false
17
+
18
+ @models = [options.delete(:model)]
19
+ @titles = [options.delete(:title)]
20
+ @descriptions = [options.delete(:description)]
21
+
22
+ super(nil, *args)
23
+
24
+ @ignore_nil = false
25
+ end
26
+
27
+ def schema!
28
+ {type: type}.merge(type == :object ? _object(**attributes.merge) : attributes)
29
+ end
30
+
31
+ def set!(key, value = BLANK, *args, **schema_options, &block)
32
+ result = if block
33
+ if !_blank?(value)
34
+ # OBJECTS ARRAY:
35
+ # json.comments @article.comments { |comment| ... }
36
+ # { "comments": [ { ... }, { ... } ] }
37
+ _scope { array! value, &block }
38
+ else
39
+ # BLOCK:
40
+ # json.comments { ... }
41
+ # { "comments": ... }
42
+ @inline_array = true
43
+ if schema_options.key?(:object)
44
+ models << schema_options[:object].class
45
+ titles << schema_options[:object_title] || nil
46
+ descriptions << schema_options[:object_description] || nil
47
+ end
48
+ r = _merge_block(key) { yield self }
49
+ if schema_options.key?(:object)
50
+ models.pop
51
+ titles.pop
52
+ descriptions.pop
53
+ end
54
+ r
55
+ end
56
+ elsif args.empty?
57
+ if ::Jbuilder === value
58
+ # ATTRIBUTE1:
59
+ # json.age 32
60
+ # json.person another_jbuilder
61
+ # { "age": 32, "person": { ... }
62
+ _schema(key, _format_keys(value.attributes!), **schema_options)
63
+ elsif _is_collection_array?(value)
64
+ # ATTRIBUTE2:
65
+ _scope { array! value }
66
+ # json.articles @articles
67
+ else
68
+ # json.age 32
69
+ # { "age": 32 }
70
+ _schema(key, _format_keys(value), **schema_options)
71
+ end
72
+ elsif _is_collection?(value)
73
+ # COLLECTION:
74
+ # json.comments @article.comments, :content, :created_at
75
+ # { "comments": [ { "content": "hello", "created_at": "..." }, { "content": "world", "created_at": "..." } ] }
76
+ @inline_array = true
77
+ @collection = true
78
+
79
+ _scope { array! value, *args }
80
+ elsif schema_options.key?(:object)
81
+ # EXTRACT!:
82
+ # json.author @article.creator, :name, :email_address
83
+ # { "author": { "name": "David", "email_address": "david@loudthinking.com" } }
84
+
85
+ models << schema_options.delete(:object).class
86
+ titles << schema_options.delete(:object_title) || nil
87
+ descriptions << schema_options.delete(:object_description) || nil
88
+ r = _merge_block(key) { extract! value, *args, **schema_options }
89
+ models.pop
90
+ titles.pop
91
+ descriptions.pop
92
+ r
93
+ else
94
+ _merge_block(key) { extract! value, *args, **schema_options }
95
+ end
96
+
97
+ result = _set_description key, result if models.any?
98
+ _set_value key, result
99
+ end
100
+
101
+ def extract!(object, *attributes, **schema_options)
102
+ schema_options = schema_options[:schema] if schema_options.key?(:schema)
103
+
104
+ if ::Hash === object
105
+ _extract_hash_values(object, attributes, **schema_options)
106
+ else
107
+ _extract_method_values(object, attributes, **schema_options)
108
+ end
109
+ end
110
+
111
+ def array!(collection = [], *args, &block)
112
+ args, schema_options = _args_and_schema_options(*args)
113
+ options = args.first
114
+
115
+ if args.one? && _partial_options?(options)
116
+ @collection = true
117
+ _set_ref(options[:partial].split("/").last)
118
+ else
119
+ array = _make_array(collection, *args, **schema_options, &block)
120
+
121
+ if @inline_array
122
+ @attributes = {}
123
+ _set_value(:type, :array)
124
+ _set_value(:items, array)
125
+ elsif _is_collection_array?(array)
126
+ @attributes = {}
127
+ @inline_array = true
128
+ @collection = true
129
+ array! array, *array.first&.attribute_names(&:to_sym)
130
+ else
131
+ @type = :array
132
+ @attributes = {}
133
+ _set_value(:items, array)
134
+ end
135
+ end
136
+ end
137
+
138
+ def partial!(*args)
139
+ if args.one? && _is_active_model?(args.first)
140
+ # TODO: Find where it is being used
141
+ _render_active_model_partial args.first
142
+ elsif args.first.is_a?(Hash)
143
+ _set_ref(args.first[:partial].split("/").last)
144
+ else
145
+ @collection = true if args[1].key?(:collection)
146
+ _set_ref(args.first&.split("/")&.last)
147
+ end
148
+ end
149
+
150
+ def merge!(object)
151
+ hash_or_array = ::Jbuilder === object ? object.attributes! : object
152
+ hash_or_array = _format_keys(hash_or_array)
153
+ if hash_or_array.is_a?(Hash)
154
+ hash_or_array = hash_or_array.each_with_object({}) do |(key, value), a|
155
+ result = _schema(key, value)
156
+ result = _set_description(key, result) if models.any?
157
+ a[key] = result
158
+ end
159
+ end
160
+ @attributes = _merge_values(@attributes, hash_or_array)
161
+ end
162
+
163
+ def cache!(key = nil, **options)
164
+ yield
165
+ end
166
+
167
+ def method_missing(*args, &block)
168
+ args, schema_options = _args_and_schema_options(*args)
169
+
170
+ if block
171
+ set!(*args, **schema_options, &block)
172
+ else
173
+ set!(*args, **schema_options)
174
+ end
175
+ end
176
+
177
+ def respond_to_missing?(method_name, include_private = false)
178
+ super
179
+ end
180
+
181
+ private
182
+
183
+ def _object(**attrs)
184
+ title = titles.last || ::I18n.t("#{models&.last&.name&.underscore&.pluralize}.#{JbuilderSchema.configuration.title_name}")
185
+ description = descriptions.last || ::I18n.t("#{models&.last&.name&.underscore&.pluralize}.#{JbuilderSchema.configuration.description_name}")
186
+ {
187
+ type: :object,
188
+ title: title,
189
+ description: description,
190
+ required: _required!(**attrs),
191
+ properties: attrs
192
+ }
193
+ end
194
+
195
+ def _args_and_schema_options(*args)
196
+ schema_options = args.extract! { |a| a.is_a?(::Hash) && a.key?(:schema) }.first.try(:[], :schema) || {}
197
+ [args, schema_options]
198
+ end
199
+
200
+ def _set_description(key, value)
201
+ unless value.key?(:description)
202
+ description = ::I18n.t("#{models.last&.name&.underscore&.pluralize}.fields.#{key}.#{JbuilderSchema.configuration.description_name}")
203
+ value = {description: description}.merge! value
204
+ end
205
+ value
206
+ end
207
+
208
+ def _set_ref(component)
209
+ if @inline_array
210
+ if @collection
211
+ _set_value(:type, :array)
212
+ _set_value(:items, {:$ref => _component_path(component)})
213
+ else
214
+ _set_value(:type, :object)
215
+ _set_value(:$ref, _component_path(component))
216
+ end
217
+ else
218
+ @type = :array
219
+ _set_value(:items, {:$ref => _component_path(component)})
220
+ end
221
+ end
222
+
223
+ def _component_path(component)
224
+ "#/#{JbuilderSchema.configuration.components_path}/#{component}"
225
+ end
226
+
227
+ def _schema(key, value, **options)
228
+ options.merge!(_guess_type(value)) unless options[:type]
229
+ options.merge!(_set_enum(key.to_s)) if models.last&.defined_enums&.keys&.include?(key.to_s)
230
+ options
231
+ end
232
+
233
+ def _guess_type(value)
234
+ type = value.class.name&.downcase&.to_sym
235
+
236
+ case type
237
+ when :datetime, :"activesupport::timewithzone"
238
+ {type: :string, format: "date-time"}
239
+ when :time, :date
240
+ {type: :string, format: type.to_s}
241
+ when :array
242
+ _guess_array_types(value)
243
+ else
244
+ {type: _type(type)}
245
+ end
246
+ end
247
+
248
+ def _guess_array_types(array)
249
+ hash = {type: :array}
250
+ types = array.map { |a| _type(a.class.name&.downcase&.to_sym) }.uniq
251
+
252
+ unless types.empty?
253
+ hash[:contains] = {type: types.size > 1 ? types : types.first}
254
+ hash[:minContains] = 0
255
+ end
256
+
257
+ hash
258
+ end
259
+
260
+ def _type(type)
261
+ case type
262
+ when :time, :date, :datetime, :"activesupport::timewithzone", nil, :text, :nilclass, :"actiontext::richtext"
263
+ :string
264
+ when :float, :bigdecimal
265
+ :number
266
+ when :trueclass, :falseclass
267
+ :boolean
268
+ else
269
+ type
270
+ end
271
+ end
272
+
273
+ def _set_enum(key)
274
+ enums = models.last&.defined_enums[key].keys
275
+ {enum: enums}
276
+ end
277
+
278
+ def _make_array(collection, *args, **schema_options, &block)
279
+ if collection.nil?
280
+ []
281
+ elsif block
282
+ _map_collection(collection, &block)
283
+ elsif args.any?
284
+ _map_collection(collection) { |element| extract! element, *args, **schema_options }
285
+ else
286
+ _format_keys(collection.to_a)
287
+ end
288
+ end
289
+
290
+ def _is_collection_array?(object)
291
+ # TODO: Find better way to determine if all array elements are models
292
+ object.is_a?(Array) && object.map { |a| _is_active_model?(a) }.uniq == [true]
293
+ end
294
+
295
+ def _required!(**attrs)
296
+ if Object.const_defined?('ActiveRecord')
297
+ attrs.keys.select { |attribute|
298
+ models.last&.validators.try(:grep, ::ActiveRecord::Validations::PresenceValidator)
299
+ .flat_map(&:attributes).unshift(_key(:id))
300
+ .include?(attribute.to_s.underscore.to_sym)
301
+ }.uniq
302
+ else
303
+ attrs.keys.include?(_key(:id)) ? [_key(:id)] : []
304
+ end
305
+ end
306
+
307
+ ###
308
+ # Jbuilder methods
309
+ ###
310
+
311
+ def _key(key)
312
+ @key_formatter ? @key_formatter.format(key).to_sym : key.to_sym
313
+ end
314
+
315
+ def _extract_hash_values(object, attributes, **schema_options)
316
+ attributes.each do |key|
317
+ result = _schema(key, _format_keys(object.fetch(key)), **schema_options[key] || {})
318
+ result = _set_description(key, result) if models.any?
319
+ _set_value key, result
320
+ end
321
+ end
322
+
323
+ def _extract_method_values(object, attributes, **schema_options)
324
+ attributes.each do |key|
325
+ result = _schema(key, _format_keys(object.public_send(key)), **schema_options[key] || {})
326
+ result = _set_description(key, result) if models.any?
327
+ _set_value key, result
328
+ end
329
+ end
330
+
331
+ def _map_collection(collection)
332
+ super.first
333
+ end
334
+
335
+ def _merge_block(key)
336
+ current_value = _blank? ? BLANK : @attributes.fetch(_key(key), BLANK)
337
+ raise NullError.build(key) if current_value.nil?
338
+
339
+ new_value = _scope { yield self }
340
+ unless new_value.key?(:type) && new_value[:type] == :array || new_value.key?(:$ref)
341
+ new_value_properties = new_value
342
+ new_value = _object(**new_value_properties)
343
+ end
344
+ _merge_values(current_value, new_value)
345
+ end
346
+ end
347
+ end
348
+
349
+ class Jbuilder
350
+ # Monkey-patch for Jbuilder::KeyFormatter to ignore schema keys
351
+ class KeyFormatter
352
+ alias_method :original_format, :format
353
+
354
+ def format(key)
355
+ return key if %i[type items properties].include?(key)
356
+
357
+ original_format(key)
358
+ end
359
+ end
360
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JbuilderSchema
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jbuilder/schema/version"
4
+ require "jbuilder/schema/configuration"
5
+ require "jbuilder/schema/builder"
6
+
7
+ # Main gem module with configuration and helper methods
8
+ module JbuilderSchema
9
+ def jbuilder_schema(path, **options)
10
+ Builder.new(path, **options).schema!
11
+ end
12
+ end
@@ -0,0 +1,4 @@
1
+ module JbuilderSchema
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,102 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jbuilder-schema
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Yuri Sidorov
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2022-10-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jbuilder
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: safe_parser
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 5.0.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 5.0.0
55
+ description: Generate JSON Schema from Jbuilder files
56
+ email:
57
+ - hey@yurisidorov.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - ".standard.yml"
63
+ - Gemfile
64
+ - Gemfile.lock
65
+ - LICENSE.txt
66
+ - README.md
67
+ - Rakefile
68
+ - lib/jbuilder/jbuilder.rb
69
+ - lib/jbuilder/schema.rb
70
+ - lib/jbuilder/schema/builder.rb
71
+ - lib/jbuilder/schema/configuration.rb
72
+ - lib/jbuilder/schema/renderer.rb
73
+ - lib/jbuilder/schema/resolver.rb
74
+ - lib/jbuilder/schema/template.rb
75
+ - lib/jbuilder/schema/version.rb
76
+ - sig/jbuilder/schema.rbs
77
+ homepage: https://github.com/newstler/jbuilder-schema
78
+ licenses:
79
+ - MIT
80
+ metadata:
81
+ homepage_uri: https://github.com/newstler/jbuilder-schema
82
+ source_code_uri: https://github.com/newstler/jbuilder-schema
83
+ post_install_message:
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 2.6.0
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 3.3.7
99
+ signing_key:
100
+ specification_version: 4
101
+ summary: Generate JSON Schema from Jbuilder files
102
+ test_files: []