typespec_from_serializers 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8e14e0d2601ec8ee7b5efa388163e3997830d825b6f98f9df7e40ecd0248a8f6
4
+ data.tar.gz: d15e88b3aa25f587697c2614b867ccaa31b3438c08d11ca12bd17ddf752313b8
5
+ SHA512:
6
+ metadata.gz: a2a246f2e90ce1fb583ce3bd1abc4c621c36f69fca705c853360e371beb22f668aa51ba043e4b7c3fdec9e2055577dd5df9b92c645984a4af81543aec546826b
7
+ data.tar.gz: 47249bf5f3c9f9af0c801790f65d0d8aa829071770f7590c15897c73fe286af61922bf92d9985bea9acb201c7d43b8c9fb5d84f2a5e71bb9720dd2a2c1d90fce
data/CHANGELOG.md ADDED
@@ -0,0 +1,102 @@
1
+ # [2.3.0](https://github.com/ElMassimo/types_from_serializers/compare/types_from_serializers@2.2.0...types_from_serializers@2.3.0) (2024-08-23)
2
+
3
+
4
+ ### Features
5
+
6
+ * generate types for inline serializers, exclude them from the index ([1c3657c](https://github.com/ElMassimo/types_from_serializers/commit/1c3657c61a1bc891f3219f6eaf8557cd3cd6344a)), closes [#19](https://github.com/ElMassimo/types_from_serializers/issues/19)
7
+
8
+
9
+
10
+ # [2.2.0](https://github.com/ElMassimo/types_from_serializers/compare/types_from_serializers@2.1.0...types_from_serializers@2.2.0) (2024-08-23)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * be more accurate regarding decimal serialization ([cd63653](https://github.com/ElMassimo/types_from_serializers/commit/cd636530a5710112a14746cc7d0e3f15016cd5e1))
16
+
17
+
18
+ ### Features
19
+
20
+ * infer type from enums ([#20](https://github.com/ElMassimo/types_from_serializers/issues/20)) ([49dc61d](https://github.com/ElMassimo/types_from_serializers/commit/49dc61da2718256e9b5f5743b5a65c4746d64c2f))
21
+
22
+
23
+
24
+ # [2.1.0](https://github.com/ElMassimo/types_from_serializers/compare/types_from_serializers@2.0.2...types_from_serializers@2.1.0) (2023-07-19)
25
+
26
+
27
+ ### Features
28
+
29
+ * add `namespace` option to generate `.d.ts` files ([#9](https://github.com/ElMassimo/types_from_serializers/issues/9)) ([6f67b1a](https://github.com/ElMassimo/types_from_serializers/commit/6f67b1ad9283868e8e3325042645bceccc85b047))
30
+
31
+
32
+
33
+ ## [2.0.2](https://github.com/ElMassimo/types_from_serializers/compare/types_from_serializers@2.0.1...types_from_serializers@2.0.2) (2023-04-05)
34
+
35
+
36
+ ### Features
37
+
38
+ * map citext from PostgreSQL to string ([#7](https://github.com/ElMassimo/types_from_serializers/issues/7)) ([d8c6848](https://github.com/ElMassimo/types_from_serializers/commit/d8c6848b99b0f4ba3770871f491755c229a2c4b0))
39
+
40
+
41
+
42
+ ## [2.0.1](https://github.com/ElMassimo/types_from_serializers/compare/types_from_serializers@2.0.0...types_from_serializers@2.0.1) (2023-04-03)
43
+
44
+
45
+ ### Bug Fixes
46
+
47
+ * `add_attribute` now expects keyword arguments ([154b49e](https://github.com/ElMassimo/types_from_serializers/commit/154b49e463e3e6533b21520b7f0d699e6f0f47ba))
48
+
49
+
50
+
51
+ ## [2.0.0](https://github.com/ElMassimo/types_from_serializers/compare/types_from_serializers@0.1.2...types_from_serializers@2.0.0) (2023-04-02)
52
+
53
+ This version adds support for `oj_serializers-2.0.2`, supporting all changes in:
54
+
55
+ - https://github.com/ElMassimo/oj_serializers/pull/9
56
+
57
+ ### Features ✨
58
+
59
+ - Now keys will match the [`transform_keys`](https://github.com/ElMassimo/oj_serializers#transforming-attribute-keys-) configuration instead of always being camelized
60
+ - Support for [`flat_one`](https://github.com/ElMassimo/oj_serializers#composing-serializers-)
61
+ - Use relative paths for imports to make the output configuration more flexible
62
+ - Define the order of properties in the interface with `sort_properties_by`
63
+
64
+ ## [0.1.3](https://github.com/ElMassimo/types_from_serializers/compare/types_from_serializers@0.1.2...types_from_serializers@0.1.3) (2022-07-12)
65
+
66
+
67
+ ### Features
68
+
69
+ * apply the sql mapping fallback as the default ([64898c4](https://github.com/ElMassimo/types_from_serializers/commit/64898c4e3a3f83ea67294f2200f253cd2a64aea9))
70
+
71
+
72
+
73
+ ## [0.1.2](https://github.com/ElMassimo/types_from_serializers/compare/types_from_serializers@0.1.1...types_from_serializers@0.1.2) (2022-07-12)
74
+
75
+
76
+ ### Bug Fixes
77
+
78
+ * avoid having the full file path in the cache key ([556f8f6](https://github.com/ElMassimo/types_from_serializers/commit/556f8f667608fa950a3ad0647540055b1b5f1dc8))
79
+
80
+
81
+
82
+ ## [0.1.1](https://github.com/ElMassimo/types_from_serializers/compare/types_from_serializers@0.1.0...types_from_serializers@0.1.1) (2022-07-12)
83
+
84
+
85
+
86
+ # 0.1.0 (2022-07-12)
87
+
88
+
89
+ ### Features
90
+
91
+ - Start simple, no additional syntax required
92
+ - Infers types from a related `ActiveRecord` model, using the SQL schema
93
+ - Understands JS native types and how to map SQL columns: `string`, `boolean`, etc
94
+ - Automatically types [associations](https://github.com/ElMassimo/oj_serializers#associations-dsl-), importing the generated types for the referenced serializers
95
+ - Detects [conditional attributes](https://github.com/ElMassimo/oj_serializers#rendering-an-attribute-conditionally) and marks them as optional: `name?: string`
96
+ - Fallback to a custom interface using `type_from`
97
+ - Supports custom types and automatically adds the necessary imports
98
+ - handle non-ActiveRecord models and extract types from unions ([ea9b2a7](https://github.com/ElMassimo/types_from_serializers/commit/ea9b2a71cb85503ff691e5ef115ab73f89b005af))
99
+ - support specifying base serializers and additional dirs to scan ([164cfe1](https://github.com/ElMassimo/types_from_serializers/commit/164cfe17bb0527c59cf95441381aef7bf797a568))
100
+
101
+
102
+
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2021 Máximo Mussini
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ <h1 align="center">
2
+ TypeSpec From Serializers
3
+ <p align="center">
4
+ <a href="https://travis-ci.org/dannote/typespec_from_serializers"><img alt="Build Status" src="https://travis-ci.org/dannote/typespec_from_serializers.svg"/></a>
5
+ <a href="http://inch-ci.org/github/dannote/typespec_from_serializers"><img alt="Inline docs" src="http://inch-ci.org/github/dannote/typespec_from_serializers.svg"/></a>
6
+ <a href="https://codeclimate.com/github/dannote/typespec_from_serializers"><img alt="Maintainability" src="https://codeclimate.com/github/dannote/typespec_from_serializers/badges/gpa.svg"/></a>
7
+ <a href="https://codeclimate.com/github/dannote/typespec_from_serializers"><img alt="Test Coverage" src="https://codeclimate.com/github/dannote/typespec_from_serializers/badges/coverage.svg"/></a>
8
+ <a href="https://rubygems.org/gems/typespec_from_serializers"><img alt="Gem Version" src="https://img.shields.io/gem/v/typespec_from_serializers.svg?colorB=e9573f"/></a>
9
+ <a href="https://github.com/dannote/typespec_from_serializers/blob/main/LICENSE.txt"><img alt="License" src="https://img.shields.io/badge/license-MIT-428F7E.svg"/></a>
10
+ </p>
11
+ </h1>
12
+
13
+ [aliases]: https://vite-ruby.netlify.app/guide/development.html#import-aliases-%F0%9F%91%89
14
+ [config options]: https://github.com/dannote/typespec_from_serializers/blob/main/lib/typespec_from_serializers/generator.rb#L82-L85
15
+ [readme]: https://github.com/dannote/typespec_from_serializers
16
+
17
+ **TypeSpec From Serializers** is a Ruby gem that automatically generates [TypeSpec](https://typespec.io) definitions from Ruby serializers and Rails routes. It is a derivative work of [`types_from_serializers`][types_from_serializers] by ElMassimo, originally designed to generate TypeScript definitions. This fork adapts the core functionality to produce TypeSpec descriptions, enabling Rails developers to define APIs compatible with TypeSpec’s ecosystem, including OpenAPI generation and client/server code scaffolding.
18
+
19
+ For more information, check the main [README].
20
+
21
+ ### Installation 💿
22
+
23
+ Add this line to your application's Gemfile:
24
+
25
+ ```ruby
26
+ gem 'typespec_from_serializers'
27
+ ```
28
+
29
+ And then execute:
30
+
31
+ $ bundle
32
+
33
+ Or install it yourself as:
34
+
35
+ $ gem install typespec_from_serializers
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ # Internal: A DSL to specify types for serializer attributes.
6
+ module TypeSpecFromSerializers
7
+ module DSL
8
+ extend ActiveSupport::Concern
9
+
10
+ module ClassMethods
11
+ # Override: Capture the name of the model related to the serializer.
12
+ #
13
+ # name - An alias for the internal object in the serializer.
14
+ # model - The name of an ActiveRecord model to infer types from the schema.
15
+ # typespec_from - The name of a TypeScript model to infer types from.
16
+ def object_as(name, model: nil, typespec_from: nil)
17
+ # NOTE: Avoid taking memory for type information that won't be used.
18
+ if Rails.env.development?
19
+ model ||= name.is_a?(Symbol) ? name : try(:_serializer_model_name) || name
20
+ define_singleton_method(:_serializer_model_name) { model }
21
+ define_singleton_method(:_serializer_typespec_from) { typespec_from } if typespec_from
22
+ end
23
+
24
+ super(name)
25
+ end
26
+
27
+ # Public: Shortcut for typing a serializer attribute.
28
+ #
29
+ # It specifies the type for a serializer method that will be defined
30
+ # immediately after calling this method.
31
+ def type(type, **options)
32
+ attribute type: type, **options
33
+ end
34
+
35
+ private
36
+
37
+ # Override: Remove unnecessary options in production, types are only
38
+ # used when generating code in development.
39
+ unless Rails.env.development?
40
+ def add_attribute(name, type: nil, optional: nil, **options)
41
+ super(name, **options)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,622 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "pathname"
6
+
7
+ # Public: Automatically generates TypeSpec descriptions for Ruby serializers and Rails routes.
8
+ module TypeSpecFromSerializers
9
+ DEFAULT_TRANSFORM_KEYS = ->(key) { key.camelize(:lower).chomp("?") }
10
+
11
+ # Internal: Extensions that simplify the implementation of the generator.
12
+ module SerializerRefinements
13
+ refine String do
14
+ # Internal: Converts a name such as :user to the User constant.
15
+ def to_model
16
+ classify.safe_constantize
17
+ end
18
+ end
19
+
20
+ refine Symbol do
21
+ def safe_constantize
22
+ to_s.classify.safe_constantize
23
+ end
24
+
25
+ def to_model
26
+ to_s.to_model
27
+ end
28
+ end
29
+
30
+ refine Class do
31
+ # Internal: Name of the TypeSpec model.
32
+ def tsp_name
33
+ TypeSpecFromSerializers.config.name_from_serializer.call(name).tr_s(":", "")
34
+ end
35
+
36
+ # Internal: The base name of the TypeSpec file to be written.
37
+ def tsp_filename
38
+ TypeSpecFromSerializers.config.name_from_serializer.call(name).gsub("::", "/")
39
+ end
40
+
41
+ # Internal: If the serializer was defined inside a file.
42
+ def inline_serializer?
43
+ name.include?("Serializer::")
44
+ end
45
+
46
+ # Internal: The TypeSpec properties of the serialzeir model.
47
+ def tsp_properties
48
+ @tsp_properties ||= begin
49
+ model_class = _serializer_model_name&.to_model
50
+ model_columns = model_class.try(:columns_hash) || {}
51
+ model_enums = model_class.try(:defined_enums) || {}
52
+ typespec_from = try(:_serializer_typespec_from)
53
+
54
+ prepare_attributes(
55
+ sort_by: TypeSpecFromSerializers.config.sort_properties_by,
56
+ transform_keys: TypeSpecFromSerializers.config.transform_keys || try(:_transform_keys) || DEFAULT_TRANSFORM_KEYS,
57
+ )
58
+ .flat_map { |key, options|
59
+ if options[:association] == :flat
60
+ options.fetch(:serializer).tsp_properties
61
+ else
62
+ Property.new(
63
+ name: key,
64
+ type: options[:serializer] || options[:type],
65
+ optional: options[:optional] || options.key?(:if),
66
+ multi: options[:association] == :many,
67
+ column_name: options.fetch(:value_from),
68
+ ).tap do |property|
69
+ property.infer_typespec_from(model_columns, model_enums, typespec_from)
70
+ end
71
+ end
72
+ }
73
+ end
74
+ end
75
+
76
+ # Internal: A first pass of gathering types for the serializer attributes.
77
+ def tsp_model
78
+ @tsp_model ||= Interface.new(
79
+ name: tsp_name,
80
+ filename: tsp_filename,
81
+ properties: tsp_properties,
82
+ )
83
+ end
84
+ end
85
+ end
86
+
87
+ # Internal: The configuration for TypeSpec generation.
88
+ Config = Struct.new(
89
+ :base_serializers,
90
+ :serializers_dirs,
91
+ :output_dir,
92
+ :custom_typespec_dir,
93
+ :name_from_serializer,
94
+ :global_types,
95
+ :sort_properties_by,
96
+ :sql_to_typespec_type_mapping,
97
+ :action_to_operation_mapping,
98
+ :skip_serializer_if,
99
+ :transform_keys,
100
+ :namespace,
101
+ keyword_init: true,
102
+ ) do
103
+ def relative_custom_typespec_dir
104
+ @relative_custom_typespec_dir ||= (custom_typespec_dir || output_dir.parent).relative_path_from(output_dir.join("models"))
105
+ end
106
+
107
+ def unknown_type
108
+ :unknown
109
+ end
110
+ end
111
+
112
+ # Internal: Information to generate a TypeSpec model for a serializer.
113
+ Interface = Struct.new(
114
+ :name,
115
+ :filename,
116
+ :properties,
117
+ keyword_init: true,
118
+ ) do
119
+ using SerializerRefinements
120
+
121
+ def inspect
122
+ to_h.inspect
123
+ end
124
+
125
+ # Internal: Returns a list of imports for types used in this model.
126
+ def used_imports
127
+ association_serializers, attribute_types = properties.map(&:type).compact.uniq
128
+ .partition { |type| type.respond_to?(:tsp_model) }
129
+
130
+ serializer_type_imports = association_serializers.map(&:tsp_model)
131
+ .map { |type| [type.name, relative_path(type.pathname, pathname)] }
132
+
133
+ custom_type_imports = attribute_types
134
+ .flat_map { |type| extract_typespec_types(type.to_s) }
135
+ .uniq
136
+ .reject { |type| global_type?(type) }
137
+ .map { |type|
138
+ type_path = TypeSpecFromSerializers.config.relative_custom_typespec_dir.join(type)
139
+ [type, relative_path(type_path, pathname)]
140
+ }
141
+
142
+ (custom_type_imports + serializer_type_imports)
143
+ .map { |model, filename| %(import "#{filename}.tsp";\n) }
144
+ end
145
+
146
+ def as_typespec
147
+ indent = TypeSpecFromSerializers.config.namespace ? 2 : 1
148
+ <<~TSP.gsub(/\n$/, "")
149
+ model #{name} {
150
+ #{" " * indent}#{properties.index_by(&:name).values.map(&:as_typespec).join("\n#{" " * indent}")}
151
+ #{" " * (indent - 1)}}
152
+ TSP
153
+ end
154
+
155
+ protected
156
+
157
+ def pathname
158
+ @pathname ||= Pathname.new(filename)
159
+ end
160
+
161
+ # Internal: Calculates a relative path that can be used in an import.
162
+ def relative_path(target_path, importer_path)
163
+ path = target_path.relative_path_from(importer_path.parent).to_s
164
+ path.start_with?(".") ? path : "./#{path}"
165
+ end
166
+
167
+ # Internal: Extracts any types inside generics or array types.
168
+ def extract_typespec_types(type)
169
+ type.split(".").first
170
+ end
171
+
172
+ # NOTE: Treat uppercase names as custom types.
173
+ # Lowercase names would be native types, such as :string and :boolean.
174
+ def global_type?(type)
175
+ type[0] == type[0].downcase || TypeSpecFromSerializers.config.global_types.include?(type)
176
+ end
177
+ end
178
+
179
+ # Internal: The type metadata for a serializer attribute.
180
+ Property = Struct.new(
181
+ :name,
182
+ :type,
183
+ :optional,
184
+ :multi,
185
+ :column_name,
186
+ keyword_init: true,
187
+ ) do
188
+ using SerializerRefinements
189
+
190
+ def inspect
191
+ to_h.inspect
192
+ end
193
+
194
+ # Internal: Infers the property's type by checking a corresponding SQL
195
+ # column, or falling back to a TypeSpec model if provided.
196
+ def infer_typespec_from(columns_hash, defined_enums, tsp_model)
197
+ if type
198
+ type
199
+ elsif (enum = defined_enums[column_name.to_s])
200
+ self.type = enum.keys.map(&:inspect).join(" | ")
201
+ elsif (column = columns_hash[column_name.to_s])
202
+ self.multi = true if column.try(:array)
203
+ self.optional = true if column.null && !column.default
204
+ self.type = TypeSpecFromSerializers.config.sql_to_typespec_type_mapping[column.type]
205
+ elsif tsp_model
206
+ self.type = "#{tsp_model}.#{name}::type"
207
+ end
208
+ end
209
+
210
+ def as_typespec
211
+ type_str = if type.respond_to?(:tsp_name)
212
+ type.tsp_name
213
+ else
214
+ type || TypeSpecFromSerializers.config.unknown_type
215
+ end
216
+
217
+ "#{name}#{"?" if optional}: #{type_str}#{"[]" if multi};"
218
+ end
219
+ end
220
+
221
+ # Internal: Represents a TypeSpec resource interface
222
+ Resource = Struct.new(:name, :path, :operations, keyword_init: true) do
223
+ def as_typespec
224
+ <<~TSP
225
+ #{" " * 1}@route("#{path}")
226
+ #{" " * 1}interface #{name} {
227
+ #{" " * 1}#{operations.map(&:as_typespec).join("\n ")}
228
+ #{" " * 1}}
229
+ TSP
230
+ end
231
+ end
232
+
233
+ # Internal: Represents a TypeSpec operation within a resource
234
+ Operation = Struct.new(:method, :action, :path_params, :response_type, keyword_init: true) do
235
+ def as_typespec
236
+ method_map = {
237
+ "GET" => "get",
238
+ "POST" => "post",
239
+ "PUT" => "put",
240
+ "PATCH" => "patch",
241
+ "DELETE" => "delete",
242
+ }
243
+ tsp_method = method_map[method] || method.downcase
244
+ operation_name = TypeSpecFromSerializers.config.action_to_operation_mapping[action] || action
245
+ params = params_typespec
246
+ params_str = params.empty? ? "()" : "(#{params})"
247
+
248
+ "#{" " * 1}@#{tsp_method} #{operation_name}#{params_str}: #{response_type.gsub("::", "")};"
249
+ end
250
+
251
+ def params_typespec
252
+ params = []
253
+ params += path_params.map { |param| "@path #{param}: string" } if path_params.any?
254
+ params << "@body body: #{response_type.gsub("::", "")}" if %w[POST PUT PATCH].include?(method)
255
+ params.join(", ")
256
+ end
257
+ end
258
+
259
+ # Internal: Structure to keep track of changed files.
260
+ class Changes
261
+ def initialize(dirs)
262
+ @added = Set.new
263
+ @removed = Set.new
264
+ @modified = Set.new
265
+ track_changes(dirs)
266
+ end
267
+
268
+ def updated?
269
+ @modified.any? || @added.any? || @removed.any?
270
+ end
271
+
272
+ def any_removed?
273
+ @removed.any?
274
+ end
275
+
276
+ def modified_files
277
+ @modified
278
+ end
279
+
280
+ def only_modified?
281
+ @added.empty? && @removed.empty?
282
+ end
283
+
284
+ def clear
285
+ @added.clear
286
+ @removed.clear
287
+ @modified.clear
288
+ end
289
+
290
+ private
291
+
292
+ def track_changes(dirs)
293
+ Listen.to(*dirs, only: %r{.rb$}) do |modified, added, removed|
294
+ modified.each { |file| @modified.add(file) }
295
+ added.each { |file| @added.add(file) }
296
+ removed.each { |file| @removed.add(file) }
297
+ end.start
298
+ end
299
+ end
300
+
301
+ class << self
302
+ using SerializerRefinements
303
+
304
+ attr_reader :force_generation
305
+
306
+ # Public: Configuration of the code generator.
307
+ def config
308
+ (@config ||= default_config(root)).tap do |config|
309
+ yield(config) if block_given?
310
+ end
311
+ end
312
+
313
+ # Public: Generates code for all serializers in the app.
314
+ def generate(force: ENV["SERIALIZER_TYPESPEC_FORCE"])
315
+ @force_generation = force
316
+ config.output_dir.rmtree if force && config.output_dir.exist?
317
+
318
+ if config.namespace
319
+ load_serializers(all_serializer_files) if force
320
+ else
321
+ generate_index_file
322
+ end
323
+
324
+ generate_routes
325
+
326
+ loaded_serializers.each do |serializer|
327
+ generate_model_for(serializer)
328
+ end
329
+ end
330
+
331
+ def generate_changed
332
+ if changes.updated?
333
+ config.output_dir.rmtree if changes.any_removed?
334
+ load_serializers(changes.modified_files)
335
+ generate
336
+ changes.clear
337
+ end
338
+ end
339
+
340
+ # Internal: Defines a TypeSpec model for the serializer.
341
+ def generate_model_for(serializer)
342
+ model = serializer.tsp_model
343
+
344
+ write_if_changed(filename: "models/#{model.filename}", cache_key: model.inspect, extension: "tsp") {
345
+ serializer_model_content(model)
346
+ }
347
+ end
348
+
349
+ # Internal: Allows to import all serializer types from a single file.
350
+ def generate_index_file
351
+ cache_key = all_serializer_files.map { |file| file.delete_prefix(root.to_s) }.join
352
+ write_if_changed(filename: "index", cache_key: cache_key) {
353
+ load_serializers(all_serializer_files)
354
+ serializers_index_content(loaded_serializers)
355
+ }
356
+ end
357
+
358
+ # Internal: Generates TypeSpec routes from Rails routes
359
+ def generate_routes
360
+ return unless defined?(Rails) && Rails.application
361
+
362
+ routes = collect_rails_routes
363
+ cache_key = routes.map { |r| r.operations.map { |op| "#{op.method}#{r.path}#{op.action}" }.join }.join
364
+ write_if_changed(filename: "routes", cache_key: cache_key) {
365
+ routes_content(routes)
366
+ }
367
+ end
368
+
369
+ # Internal: Checks if it should avoid generating an model.
370
+ def skip_serializer?(serializer)
371
+ serializer.name.in?(config.base_serializers) ||
372
+ config.skip_serializer_if.call(serializer)
373
+ end
374
+
375
+ # Internal: Returns an object compatible with FileUpdateChecker.
376
+ def track_changes
377
+ changes
378
+ end
379
+
380
+ private
381
+
382
+ def root
383
+ defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd)
384
+ end
385
+
386
+ def changes
387
+ @changes ||= Changes.new(config.serializers_dirs)
388
+ end
389
+
390
+ def all_serializer_files
391
+ config.serializers_dirs.flat_map { |dir| Dir["#{dir}/**/*.rb"] }.sort
392
+ end
393
+
394
+ def load_serializers(files)
395
+ files.each { |file| require file }
396
+ end
397
+
398
+ def loaded_serializers
399
+ config.base_serializers.map(&:constantize)
400
+ .flat_map(&:descendants)
401
+ .uniq
402
+ .sort_by(&:name)
403
+ .reject { |s| skip_serializer?(s) }
404
+ rescue NameError
405
+ raise ArgumentError, "Please ensure all your serializers extend BaseSerializer, or configure `config.base_serializers`."
406
+ end
407
+
408
+ # Internal: Collects routes from Rails and groups them into resources
409
+ def collect_rails_routes
410
+ return [] unless defined?(Rails) && Rails.application
411
+
412
+ routes_by_controller = Rails.application.routes.routes.each_with_object(Hash.new { |h, k| h[k] = [] }) do |route, hash|
413
+ next unless route.defaults[:controller] && route.verb.present?
414
+
415
+ controller = route.defaults[:controller]
416
+ action = route.defaults[:action]
417
+ method = route.verb.split("|").first
418
+ path = route.path.spec.to_s.sub("(.:format)", "")
419
+ response_type = infer_response_type(controller, action) || "unknown"
420
+
421
+ unless hash[controller].any? { |r| r[:method] == method && r[:action] == action }
422
+ hash[controller] << {
423
+ method: method,
424
+ action: action,
425
+ path: path,
426
+ response_type: response_type,
427
+ }
428
+ end
429
+ end
430
+
431
+ routes_by_controller.map do |controller, routes|
432
+ path_segments = routes.map { |r| r[:path].split("/")[1..-1] || [] }.uniq.sort_by(&:length)
433
+ base_path = path_segments.any? ? path_segments.first.join("/")&.split("/{")&.first || controller : controller
434
+
435
+ operations = routes.map do |route|
436
+ path_params = route[:path].scan(/{([^}]+)}/).flatten
437
+ response_type = if route[:response_type] == route[:action]
438
+ "unknown"
439
+ else
440
+ (route[:action] == "index") ? "#{route[:response_type]}[]" : route[:response_type]
441
+ end
442
+ Operation.new(
443
+ method: route[:method],
444
+ action: route[:action],
445
+ path_params: (route[:action] == "show") ? ["id"] : path_params,
446
+ response_type: response_type,
447
+ )
448
+ end
449
+ Resource.new(
450
+ name: controller.tr("/", "_").camelize,
451
+ path: "/#{base_path}",
452
+ operations: operations,
453
+ )
454
+ end
455
+ end
456
+
457
+ # Internal: Infers the response type based on controller and action
458
+ def infer_response_type(controller, action)
459
+ controller_class = "#{controller.camelize}Controller".safe_constantize
460
+ return nil unless controller_class
461
+
462
+ model_name = controller.singularize.camelize
463
+ serializer_class = "#{model_name}Serializer".safe_constantize
464
+ serializer_class&.tsp_name
465
+ end
466
+
467
+ # Internal: Generates the routes.tsp content with resources
468
+ def routes_content(routes)
469
+ imports = routes.flat_map { |r| r.operations.map(&:response_type) }.compact.uniq.map do |type|
470
+ base_type = (type || "unknown").split("[]").first.gsub("::", "")
471
+ next if base_type == "unknown"
472
+ relative_path = "./#{base_type}.tsp"
473
+ %(import "#{relative_path}";\n)
474
+ end.compact.uniq.join
475
+
476
+ resources = routes.map(&:as_typespec).join("\n").strip
477
+ <<~TSP
478
+ //
479
+ // DO NOT MODIFY: This file was automatically generated by TypeSpecFromSerializers.
480
+ import "@typespec/http";
481
+
482
+ #{imports}
483
+ using TypeSpec.Http;
484
+
485
+ namespace Routes {
486
+ #{resources}
487
+ }
488
+ TSP
489
+ end
490
+
491
+ def default_config(root)
492
+ Config.new(
493
+ # The base serializers that all other serializers extend.
494
+ base_serializers: ["BaseSerializer"],
495
+
496
+ # The dirs where the serializer files are located.
497
+ serializers_dirs: [root.join("app/serializers").to_s],
498
+
499
+ # The dir where model files are placed.
500
+ output_dir: root.join(defined?(ViteRuby) ? ViteRuby.config.source_code_dir : "app/frontend").join("typespec/generated"),
501
+
502
+ # Remove the serializer suffix from the class name.
503
+ name_from_serializer: ->(name) {
504
+ name.split("::").map { |n| n.delete_suffix("Serializer") }.join("::")
505
+ },
506
+
507
+ # Types that don't need to be imported in TypeSpec.
508
+ global_types: [
509
+ "Array",
510
+ "Record",
511
+ "Date",
512
+ ].to_set,
513
+
514
+ # Allows to choose a different sort order, alphabetical by default.
515
+ sort_properties_by: :name,
516
+
517
+ # Allows to avoid generating a serializer.
518
+ skip_serializer_if: ->(serializer) { false },
519
+
520
+ # Maps SQL column types to TypeSpec native and custom types.
521
+ sql_to_typespec_type_mapping: {
522
+ boolean: :boolean,
523
+ date: :plainDate,
524
+ datetime: :utcDateTime,
525
+ timestamp: :utcDateTime,
526
+ timestamptz: :offsetDateTime,
527
+ time: :plainTime,
528
+ decimal: :decimal128,
529
+ numeric: :decimal128,
530
+ integer: :int32,
531
+ bigint: :int64,
532
+ smallint: :int16,
533
+ tinyint: :int8,
534
+ float: :float32,
535
+ double: :float64,
536
+ real: :float32,
537
+ string: :string,
538
+ text: :string,
539
+ citext: :string,
540
+ binary: :bytes,
541
+ blob: :bytes,
542
+ json: "Record<string, unknown>",
543
+ jsonb: "Record<string, unknown>",
544
+ uuid: :string,
545
+ },
546
+
547
+ # Map Rails actions to TypeSpec operations
548
+ action_to_operation_mapping: {
549
+ "index" => "list",
550
+ "show" => "read",
551
+ "create" => "create",
552
+ "update" => "update",
553
+ "destroy" => "delete",
554
+ },
555
+
556
+ # Allows to transform keys, useful when converting objects client-side.
557
+ transform_keys: nil,
558
+
559
+ # Allows scoping typespec definitions to a namespace
560
+ namespace: nil,
561
+ )
562
+ end
563
+
564
+ # Internal: Writes if the file does not exist or the cache key has changed.
565
+ # The cache strategy consists of a comment on the first line of the file.
566
+ #
567
+ # Yields to receive the rendered file content when it needs to.
568
+ def write_if_changed(filename:, cache_key:, extension: "tsp")
569
+ filename = config.output_dir.join("#{filename}.#{extension}")
570
+ FileUtils.mkdir_p(filename.dirname)
571
+ cache_key_comment = "// TypeSpecFromSerializers CacheKey #{Digest::MD5.hexdigest(cache_key)}\n"
572
+ File.open(filename, "a+") { |file|
573
+ if stale?(file, cache_key_comment)
574
+ file.truncate(0)
575
+ file.write(cache_key_comment)
576
+ file.write(yield)
577
+ end
578
+ }
579
+ end
580
+
581
+ def serializers_index_content(serializers)
582
+ <<~TSP
583
+ //
584
+ // DO NOT MODIFY: This file was automatically generated by TypeSpecFromSerializers.
585
+
586
+ import "./routes.tsp";
587
+ #{serializers.reject(&:inline_serializer?).map { |s|
588
+ %(import "./models/#{s.tsp_filename}.tsp";)
589
+ }.join("\n")}
590
+ TSP
591
+ end
592
+
593
+ def serializer_model_content(model)
594
+ config.namespace ? declaration_model_definition(model) : standard_model_definition(model)
595
+ end
596
+
597
+ def standard_model_definition(model)
598
+ <<~TSP
599
+ //
600
+ // DO NOT MODIFY: This file was automatically generated by TypeSpecFromSerializers.
601
+ #{model.used_imports.join}
602
+ #{model.as_typespec}
603
+ TSP
604
+ end
605
+
606
+ def declaration_model_definition(model)
607
+ <<~TSP
608
+ //
609
+ // DO NOT MODIFY: This file was automatically generated by TypeSpecFromSerializers.
610
+ #{model.used_imports.empty? ? "export {}\n" : model.used_imports.join}
611
+ namespace #{config.namespace} {
612
+ #{model.as_typespec}
613
+ }
614
+ TSP
615
+ end
616
+
617
+ # Internal: Returns true if the cache key has changed since the last codegen.
618
+ def stale?(file, cache_key_comment)
619
+ @force_generation || file.gets != cache_key_comment
620
+ end
621
+ end
622
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ class TypeSpecFromSerializers::Railtie < Rails::Railtie
6
+ railtie_name :typespec_from_serializers
7
+
8
+ # Automatically generates code whenever a serializer is loaded.
9
+ if defined?(Rails.env) && Rails.env.development?
10
+ require_relative "generator"
11
+
12
+ initializer "typespec_from_serializers.reloader" do |app|
13
+ if Gem.loaded_specs["listen"]
14
+ require "listen"
15
+
16
+ app.config.after_initialize do
17
+ app.reloaders << TypeSpecFromSerializers.track_changes
18
+ end
19
+
20
+ app.config.to_prepare do
21
+ TypeSpecFromSerializers.generate_changed
22
+ end
23
+ else
24
+ app.config.to_prepare do
25
+ TypeSpecFromSerializers.generate
26
+ end
27
+
28
+ Rails.logger.warn "Add 'listen' to your Gemfile to automatically generate code on serializer changes."
29
+ end
30
+ end
31
+ end
32
+
33
+ # Suitable when triggering code generation manually.
34
+ rake_tasks do |app|
35
+ namespace :typespec_from_serializers do
36
+ desc "Generates TypeSpec descriptions for each serializer in the app."
37
+ task generate: :environment do
38
+ require_relative "generator"
39
+ start_time = Time.zone.now
40
+ print "Generating TypeSpec descriptions..."
41
+ serializers = TypeSpecFromSerializers.generate(force: true)
42
+ puts "completed in #{(Time.zone.now - start_time).round(2)} seconds.\n"
43
+ puts "Found #{serializers.size} serializers:"
44
+ puts serializers.map { |s| "\t#{s.name}" }.join("\n")
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypeSpecFromSerializers
4
+ # Public: This library adheres to semantic versioning.
5
+ VERSION = "0.1.0"
6
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "typespec_from_serializers/version"
4
+ require_relative "typespec_from_serializers/dsl"
5
+ require_relative "typespec_from_serializers/railtie"
metadata ADDED
@@ -0,0 +1,285 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: typespec_from_serializers
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Danila Poyarkov
8
+ - Máximo Mussini
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2025-03-01 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: railties
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - ">="
19
+ - !ruby/object:Gem::Version
20
+ version: '5.1'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - ">="
26
+ - !ruby/object:Gem::Version
27
+ version: '5.1'
28
+ - !ruby/object:Gem::Dependency
29
+ name: oj_serializers
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: 2.0.2
35
+ - - "~>"
36
+ - !ruby/object:Gem::Version
37
+ version: '2.0'
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: 2.0.2
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ - !ruby/object:Gem::Dependency
49
+ name: listen
50
+ requirement: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.2'
55
+ type: :runtime
56
+ prerelease: false
57
+ version_requirements: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.2'
62
+ - !ruby/object:Gem::Dependency
63
+ name: bundler
64
+ requirement: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2'
69
+ type: :development
70
+ prerelease: false
71
+ version_requirements: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2'
76
+ - !ruby/object:Gem::Dependency
77
+ name: rake
78
+ requirement: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13'
83
+ type: :development
84
+ prerelease: false
85
+ version_requirements: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '13'
90
+ - !ruby/object:Gem::Dependency
91
+ name: rspec-given
92
+ requirement: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '3.8'
97
+ type: :development
98
+ prerelease: false
99
+ version_requirements: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.8'
104
+ - !ruby/object:Gem::Dependency
105
+ name: rspec-snapshot
106
+ requirement: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ type: :development
112
+ prerelease: false
113
+ version_requirements: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ - !ruby/object:Gem::Dependency
119
+ name: simplecov
120
+ requirement: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "<"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.18'
125
+ type: :development
126
+ prerelease: false
127
+ version_requirements: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "<"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.18'
132
+ - !ruby/object:Gem::Dependency
133
+ name: standard
134
+ requirement: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '1.0'
139
+ type: :development
140
+ prerelease: false
141
+ version_requirements: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '1.0'
146
+ - !ruby/object:Gem::Dependency
147
+ name: activerecord
148
+ requirement: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ type: :development
154
+ prerelease: false
155
+ version_requirements: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ">="
158
+ - !ruby/object:Gem::Version
159
+ version: '0'
160
+ - !ruby/object:Gem::Dependency
161
+ name: js_from_routes
162
+ requirement: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ">="
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ type: :development
168
+ prerelease: false
169
+ version_requirements: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ">="
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ - !ruby/object:Gem::Dependency
175
+ name: sqlite3
176
+ requirement: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '0'
181
+ type: :development
182
+ prerelease: false
183
+ version_requirements: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ">="
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ - !ruby/object:Gem::Dependency
189
+ name: rubocop
190
+ requirement: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ">="
193
+ - !ruby/object:Gem::Version
194
+ version: '0'
195
+ type: :development
196
+ prerelease: false
197
+ version_requirements: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - ">="
200
+ - !ruby/object:Gem::Version
201
+ version: '0'
202
+ - !ruby/object:Gem::Dependency
203
+ name: rubocop-rails
204
+ requirement: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - ">="
207
+ - !ruby/object:Gem::Version
208
+ version: '0'
209
+ type: :development
210
+ prerelease: false
211
+ version_requirements: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - ">="
214
+ - !ruby/object:Gem::Version
215
+ version: '0'
216
+ - !ruby/object:Gem::Dependency
217
+ name: rubocop-rspec
218
+ requirement: !ruby/object:Gem::Requirement
219
+ requirements:
220
+ - - ">="
221
+ - !ruby/object:Gem::Version
222
+ version: '0'
223
+ type: :development
224
+ prerelease: false
225
+ version_requirements: !ruby/object:Gem::Requirement
226
+ requirements:
227
+ - - ">="
228
+ - !ruby/object:Gem::Version
229
+ version: '0'
230
+ - !ruby/object:Gem::Dependency
231
+ name: rubocop-performance
232
+ requirement: !ruby/object:Gem::Requirement
233
+ requirements:
234
+ - - ">="
235
+ - !ruby/object:Gem::Version
236
+ version: '0'
237
+ type: :development
238
+ prerelease: false
239
+ version_requirements: !ruby/object:Gem::Requirement
240
+ requirements:
241
+ - - ">="
242
+ - !ruby/object:Gem::Version
243
+ version: '0'
244
+ description: typespec_from_serializers helps you by automatically generating TypeSpec
245
+ descriptions for your JSON serializers, allowing you typecheck your frontend code
246
+ to ship fast and with confidence.
247
+ email:
248
+ - maximomussini@gmail.com
249
+ executables: []
250
+ extensions: []
251
+ extra_rdoc_files:
252
+ - README.md
253
+ files:
254
+ - CHANGELOG.md
255
+ - LICENSE.txt
256
+ - README.md
257
+ - lib/typespec_from_serializers.rb
258
+ - lib/typespec_from_serializers/dsl.rb
259
+ - lib/typespec_from_serializers/generator.rb
260
+ - lib/typespec_from_serializers/railtie.rb
261
+ - lib/typespec_from_serializers/version.rb
262
+ homepage: https://github.com/dannote/typespec_from_serializers
263
+ licenses:
264
+ - MIT
265
+ metadata: {}
266
+ post_install_message:
267
+ rdoc_options: []
268
+ require_paths:
269
+ - lib
270
+ required_ruby_version: !ruby/object:Gem::Requirement
271
+ requirements:
272
+ - - ">="
273
+ - !ruby/object:Gem::Version
274
+ version: '0'
275
+ required_rubygems_version: !ruby/object:Gem::Requirement
276
+ requirements:
277
+ - - ">="
278
+ - !ruby/object:Gem::Version
279
+ version: '0'
280
+ requirements: []
281
+ rubygems_version: 3.5.16
282
+ signing_key:
283
+ specification_version: 4
284
+ summary: Generate TypeSpec descriptions from your JSON serializers.
285
+ test_files: []