typelizer 0.7.0 → 0.9.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/CHANGELOG.md +59 -2
- data/README.md +126 -0
- data/lib/tasks/generate.rake +6 -4
- data/lib/typelizer/config.rb +3 -0
- data/lib/typelizer/configuration.rb +1 -2
- data/lib/typelizer/delegate_tracker.rb +34 -0
- data/lib/typelizer/dsl.rb +5 -5
- data/lib/typelizer/generator.rb +2 -27
- data/lib/typelizer/interface.rb +54 -31
- data/lib/typelizer/model_plugins/active_record.rb +23 -0
- data/lib/typelizer/openapi.rb +214 -0
- data/lib/typelizer/property.rb +23 -4
- data/lib/typelizer/serializer_plugins/alba/{trait_attribute_collector.rb → block_attribute_collector.rb} +16 -5
- data/lib/typelizer/serializer_plugins/alba/trait_interface.rb +9 -23
- data/lib/typelizer/serializer_plugins/alba.rb +35 -8
- data/lib/typelizer/type_inference.rb +47 -0
- data/lib/typelizer/type_parser.rb +14 -0
- data/lib/typelizer/version.rb +1 -1
- data/lib/typelizer.rb +40 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8a6cb6187a0670a589346d969c61a17ae0b9fd60a4e6926c74c2e72c9594130
|
|
4
|
+
data.tar.gz: 4ef1a1e6e728df5ab1d5198a0254cfa68e5e477da57fb9c3c6b7ae2f7efa945d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56296749f1d5478e98457afac0d2e8599e360d5b7864d596772c825e9970471de8b1d0103ada0120b9adeddab2805e65976c496ce0963785dfd39ddc3dffae91
|
|
7
|
+
data.tar.gz: 25f8667ef0f54560e1c84e8d906b50abc55e01ea761dacff012657bc7d94f7127263166b81c4e317350ee89219f92a070aa042004f42cfad3a6482fe31a0c86f
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning].
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-02-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Alba: nested attributes (`nested` / `nested_attribute`) now generate inline nested TypeScript types with full type inference support, including within traits. ([@pgiblock])
|
|
15
|
+
|
|
16
|
+
- OpenAPI: support for traits in schema generation. ([@skryukov])
|
|
17
|
+
|
|
18
|
+
- Union types in `typelize` for polymorphic associations. Supports serializer class references, pipe-delimited strings, and plain TypeScript type names. ([@skryukov])
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
typelize commentable: [UserResource, CommentResource]
|
|
22
|
+
typelize approver: "AuthorResource | null"
|
|
23
|
+
typelize content: "TextBlock | ImageBlock"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- OpenAPI: TypeScript-only types (`any`, `unknown`, `never`) and generic types (`Record<string, unknown>`, `Partial<T>`, etc.) no longer produce invalid `$ref` entries. They are mapped to `{type: :object}` instead. ([@skryukov])
|
|
29
|
+
- OpenAPI: fix nullable arrays producing incorrect schemas. ([@skryukov])
|
|
30
|
+
- Fix Typelizer not loading gracefully when required gems are missing at boot time. ([@skryukov])
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **Internal:** Union types are now stored as arrays of symbols instead of pipe-delimited strings. This fixes import resolution for serializer classes inside unions and eliminates redundant string splitting/joining across the DSL, Interface, and OpenAPI layers. ([@skryukov])
|
|
35
|
+
|
|
36
|
+
## [0.8.0] - 2026-02-19
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- OpenAPI schema generation from serializers, supporting both OpenAPI 3.0 and 3.1. ([@skryukov])
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
# Get all schemas as a hash
|
|
44
|
+
Typelizer.openapi_schemas
|
|
45
|
+
# => { "Post" => { type: :object, properties: { ... }, required: [...] }, ... }
|
|
46
|
+
|
|
47
|
+
# OpenAPI 3.1 output
|
|
48
|
+
Typelizer.openapi_schemas(openapi_version: "3.1")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Column types are automatically mapped to OpenAPI types with proper formats (`integer`, `int64`, `uuid`, `date-time`, etc.).
|
|
52
|
+
Enums, nullable fields, arrays, deprecated flags, and `$ref` associations are all handled automatically.
|
|
53
|
+
|
|
54
|
+
- Type inference for delegated attributes (`delegate :name, to: :user`). Typelizer now tracks `delegate` calls on ActiveRecord models and resolves types from the target association's model, including support for `prefix` and `allow_nil` options. ([@skryukov])
|
|
55
|
+
|
|
56
|
+
- Reference other serializers in `typelize` method by passing the class directly. ([@skryukov])
|
|
57
|
+
|
|
58
|
+
- Per-writer `reject_class` configuration. Each writer can now define its own `reject_class` filter, enabling scoped output (e.g., only V1 serializers for a V1 writer). ([@skryukov])
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- `typelize` DSL metadata (optional, comment, type overrides) now correctly applies to renamed attributes (e.g., via `key:`, `alias_name`, `value_from`). Previously, metadata was looked up only by `column_name`, missing attributes where the output name differs. ([@skryukov])
|
|
63
|
+
|
|
10
64
|
## [0.7.0] - 2026-01-15
|
|
11
65
|
|
|
12
66
|
### Changed
|
|
@@ -353,12 +407,15 @@ and this project adheres to [Semantic Versioning].
|
|
|
353
407
|
[@NOX73]: https://github.com/NOX73
|
|
354
408
|
[@okuramasafumi]: https://github.com/okuramasafumi
|
|
355
409
|
[@patvice]: https://github.com/patvice
|
|
410
|
+
[@pgiblock]: https://github.com/pgiblock
|
|
411
|
+
[@prog-supdex]: https://github.com/prog-supdex
|
|
356
412
|
[@PedroAugustoRamalhoDuarte]: https://github.com/PedroAugustoRamalhoDuarte
|
|
357
413
|
[@skryukov]: https://github.com/skryukov
|
|
358
|
-
[@prog-supdex]: https://github.com/prog-supdex
|
|
359
414
|
[@ventsislaf]: https://github.com/ventsislaf
|
|
360
415
|
|
|
361
|
-
[Unreleased]: https://github.com/skryukov/typelizer/compare/v0.
|
|
416
|
+
[Unreleased]: https://github.com/skryukov/typelizer/compare/v0.9.0...HEAD
|
|
417
|
+
[0.9.0]: https://github.com/skryukov/typelizer/compare/v0.8.0...v0.9.0
|
|
418
|
+
[0.8.0]: https://github.com/skryukov/typelizer/compare/v0.7.0...v0.8.0
|
|
362
419
|
[0.7.0]: https://github.com/skryukov/typelizer/compare/v0.6.0...v0.7.0
|
|
363
420
|
[0.6.0]: https://github.com/skryukov/typelizer/compare/v0.5.6...v0.6.0
|
|
364
421
|
[0.5.6]: https://github.com/skryukov/typelizer/compare/v0.5.5...v0.5.6
|
data/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Typelizer generates TypeScript types from your Ruby serializers. It supports mul
|
|
|
16
16
|
- [Manual Generation](#manual-generation)
|
|
17
17
|
- [Automatic Generation in Development](#automatic-generation-in-development)
|
|
18
18
|
- [Disabling Typelizer](#disabling-typelizer)
|
|
19
|
+
- [OpenAPI Schema Generation](#openapi-schema-generation)
|
|
19
20
|
- [Configuration](#configuration)
|
|
20
21
|
- [Global Configuration](#simple-configuration)
|
|
21
22
|
- [Writers (multiple outputs)](#defining-multiple-writers)
|
|
@@ -128,6 +129,77 @@ class PostResource < ApplicationResource
|
|
|
128
129
|
end
|
|
129
130
|
```
|
|
130
131
|
|
|
132
|
+
You can reference other serializers directly by passing the class. Typelizer resolves the class to its generated type name automatically:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
class PostResource < ApplicationResource
|
|
136
|
+
attributes :id, :title
|
|
137
|
+
|
|
138
|
+
# Reference another serializer — resolves to its generated TypeScript type
|
|
139
|
+
typelize reviewer: [AuthorResource, {optional: true, nullable: true}]
|
|
140
|
+
attribute :reviewer do |post|
|
|
141
|
+
post.reviewer
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Self-reference works too
|
|
145
|
+
typelize previous_post: PostResource
|
|
146
|
+
attribute :previous_post do |post|
|
|
147
|
+
post.previous_post
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Union types are supported for polymorphic associations. You can use serializer class references, which resolve to their generated type names:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
class PostResource < ApplicationResource
|
|
156
|
+
attributes :id, :title
|
|
157
|
+
|
|
158
|
+
# Union of two serializers — resolves to generated type names
|
|
159
|
+
typelize commentable: [UserResource, CommentResource]
|
|
160
|
+
attribute :commentable
|
|
161
|
+
|
|
162
|
+
# Nullable union — extracts null and marks as nullable
|
|
163
|
+
typelize approver: "AuthorResource | null"
|
|
164
|
+
attribute :approver
|
|
165
|
+
|
|
166
|
+
# Pipe-delimited string with serializer names
|
|
167
|
+
typelize target: "UserResource | CommentResource"
|
|
168
|
+
attribute :target
|
|
169
|
+
|
|
170
|
+
# String and class constant can be mixed
|
|
171
|
+
typelize item: ["Namespace::UserResource", CommentResource]
|
|
172
|
+
attribute :item
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
You can also use plain TypeScript type names for custom types that aren't backed by serializers:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
class PostResource < ApplicationResource
|
|
180
|
+
attributes :id, :title
|
|
181
|
+
|
|
182
|
+
# Plain type names — passed through as-is to TypeScript
|
|
183
|
+
typelize content: "TextBlock | ImageBlock"
|
|
184
|
+
attribute :content
|
|
185
|
+
|
|
186
|
+
# Works with arrays too
|
|
187
|
+
typelize sections: ["TextBlock", "ImageBlock"]
|
|
188
|
+
attribute :sections
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
This generates:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
type Post = {
|
|
196
|
+
id: number;
|
|
197
|
+
title: string;
|
|
198
|
+
content: TextBlock | ImageBlock;
|
|
199
|
+
sections: TextBlock | ImageBlock;
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
131
203
|
For more complex type definitions, use the full API:
|
|
132
204
|
|
|
133
205
|
```ruby
|
|
@@ -310,6 +382,60 @@ Typelizer.listen = false
|
|
|
310
382
|
|
|
311
383
|
Sometimes we want to use Typelizer only with manual generation. To disable Typelizer during development, we can set `DISABLE_TYPELIZER` environment variable to `true`. This doesn't affect manual generation.
|
|
312
384
|
|
|
385
|
+
## OpenAPI Schema Generation
|
|
386
|
+
|
|
387
|
+
Typelizer can generate [OpenAPI](https://swagger.io/specification/) component schemas from your serializers. This is useful for documenting your API or integrating with tools like [rswag](https://github.com/rswag/rswag).
|
|
388
|
+
|
|
389
|
+
Get all schemas as a hash:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
Typelizer.openapi_schemas
|
|
393
|
+
# => {
|
|
394
|
+
# "Post" => {
|
|
395
|
+
# type: :object,
|
|
396
|
+
# properties: {
|
|
397
|
+
# id: { type: :integer },
|
|
398
|
+
# title: { type: :string },
|
|
399
|
+
# published_at: { type: :string, format: :"date-time", nullable: true }
|
|
400
|
+
# },
|
|
401
|
+
# required: [:id, :title]
|
|
402
|
+
# },
|
|
403
|
+
# "Author" => { ... }
|
|
404
|
+
# }
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
By default, schemas are generated for OpenAPI 3.0. Pass `openapi_version: "3.1"` for OpenAPI 3.1 output (e.g., `type: [:string, :null]` instead of `nullable: true`):
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
Typelizer.openapi_schemas(openapi_version: "3.1")
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Generate a schema for a single interface:
|
|
414
|
+
|
|
415
|
+
```ruby
|
|
416
|
+
interfaces = Typelizer.interfaces
|
|
417
|
+
post_interface = interfaces.find { |i| i.name == "Post" }
|
|
418
|
+
Typelizer::OpenAPI.schema_for(post_interface)
|
|
419
|
+
Typelizer::OpenAPI.schema_for(post_interface, openapi_version: "3.1")
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Column types are mapped to OpenAPI types automatically:
|
|
423
|
+
|
|
424
|
+
| Column type | OpenAPI type | Format |
|
|
425
|
+
|---|---|---|
|
|
426
|
+
| `integer` | `integer` | |
|
|
427
|
+
| `bigint` | `integer` | `int64` |
|
|
428
|
+
| `float` | `number` | `float` |
|
|
429
|
+
| `decimal` | `number` | `double` |
|
|
430
|
+
| `boolean` | `boolean` | |
|
|
431
|
+
| `string`, `text`, `citext` | `string` | |
|
|
432
|
+
| `uuid` | `string` | `uuid` |
|
|
433
|
+
| `date` | `string` | `date` |
|
|
434
|
+
| `datetime` | `string` | `date-time` |
|
|
435
|
+
| `time` | `string` | `time` |
|
|
436
|
+
|
|
437
|
+
Enums, nullable fields, arrays, deprecated flags, and `$ref` associations are all handled automatically.
|
|
438
|
+
|
|
313
439
|
## Configuration
|
|
314
440
|
|
|
315
441
|
Typelizer provides several global configuration options:
|
data/lib/tasks/generate.rake
CHANGED
|
@@ -19,13 +19,15 @@ namespace :typelizer do
|
|
|
19
19
|
ENV["DISABLE_TYPELIZER"] = "false"
|
|
20
20
|
|
|
21
21
|
puts "Generating TypeScript interfaces..."
|
|
22
|
-
serializers = []
|
|
23
22
|
time = Benchmark.realtime do
|
|
24
|
-
|
|
23
|
+
block.call
|
|
25
24
|
end
|
|
26
25
|
|
|
26
|
+
interfaces = Typelizer.interfaces
|
|
27
|
+
raise ArgumentError, "No serializers found. Please ensure all your serializers include Typelizer::DSL." if interfaces.empty?
|
|
28
|
+
|
|
27
29
|
puts "Finished in #{time} seconds"
|
|
28
|
-
puts "Found #{
|
|
29
|
-
puts
|
|
30
|
+
puts "Found #{interfaces.size} serializers:"
|
|
31
|
+
puts interfaces.map { |i| "\t#{i.name}" }.join("\n")
|
|
30
32
|
end
|
|
31
33
|
end
|
data/lib/typelizer/config.rb
CHANGED
|
@@ -51,6 +51,7 @@ module Typelizer
|
|
|
51
51
|
output_dir
|
|
52
52
|
inheritance_strategy
|
|
53
53
|
associations_strategy
|
|
54
|
+
reject_class
|
|
54
55
|
].freeze
|
|
55
56
|
|
|
56
57
|
Config = Struct.new(
|
|
@@ -70,6 +71,7 @@ module Typelizer
|
|
|
70
71
|
:verbatim_module_syntax,
|
|
71
72
|
:inheritance_strategy,
|
|
72
73
|
:associations_strategy,
|
|
74
|
+
:reject_class,
|
|
73
75
|
:comments,
|
|
74
76
|
:prefer_double_quotes,
|
|
75
77
|
keyword_init: true
|
|
@@ -105,6 +107,7 @@ module Typelizer
|
|
|
105
107
|
null_strategy: :nullable,
|
|
106
108
|
inheritance_strategy: :none,
|
|
107
109
|
associations_strategy: :database,
|
|
110
|
+
reject_class: ->(serializer:) { false },
|
|
108
111
|
comments: false,
|
|
109
112
|
prefer_double_quotes: false,
|
|
110
113
|
|
|
@@ -18,12 +18,11 @@ module Typelizer
|
|
|
18
18
|
class Configuration
|
|
19
19
|
DEFAULT_WRITER_NAME = :default
|
|
20
20
|
|
|
21
|
-
attr_accessor :dirs, :
|
|
21
|
+
attr_accessor :dirs, :listen
|
|
22
22
|
attr_reader :writers, :global_settings
|
|
23
23
|
|
|
24
24
|
def initialize
|
|
25
25
|
@dirs = []
|
|
26
|
-
@reject_class = ->(serializer:) { false }
|
|
27
26
|
@listen = nil
|
|
28
27
|
|
|
29
28
|
default = Config.build
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typelizer
|
|
4
|
+
module DelegateTracker
|
|
5
|
+
@registry = {} # { Class => { method_name => { to:, allow_nil:, original: } } }
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
attr_reader :registry
|
|
9
|
+
|
|
10
|
+
def [](klass, method)
|
|
11
|
+
registry.dig(klass, method)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
module Hook
|
|
16
|
+
def delegate(*methods, to:, allow_nil: nil, prefix: nil, **)
|
|
17
|
+
super.tap do
|
|
18
|
+
next unless is_a?(Class) && defined?(ActiveRecord::Base) && !ActiveRecord.autoload?(:Base) && self < ActiveRecord::Base
|
|
19
|
+
|
|
20
|
+
method_prefix = if prefix == true
|
|
21
|
+
"#{to}_"
|
|
22
|
+
else
|
|
23
|
+
prefix ? "#{prefix}_" : ""
|
|
24
|
+
end
|
|
25
|
+
methods.each do |m|
|
|
26
|
+
(DelegateTracker.registry[self] ||= {})[:"#{method_prefix}#{m}"] = {to: to, allow_nil: !!allow_nil, original: m.to_sym}
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Module.prepend(Typelizer::DelegateTracker::Hook) if Typelizer.enabled?
|
data/lib/typelizer/dsl.rb
CHANGED
|
@@ -104,16 +104,16 @@ module Typelizer
|
|
|
104
104
|
options = attrs.last.is_a?(Hash) ? attrs.pop : {}
|
|
105
105
|
|
|
106
106
|
if attrs.any?
|
|
107
|
-
# Parse type shortcuts and merge options
|
|
108
107
|
parsed_types = attrs.map { |t| TypeParser.parse(t) }
|
|
109
|
-
|
|
110
|
-
options[:type] = type_names.join(" | ")
|
|
111
|
-
|
|
112
|
-
# Merge modifier flags from all parsed types
|
|
108
|
+
all_types = parsed_types.flat_map { |p| Array(p[:type]) }
|
|
113
109
|
parsed_types.each do |parsed|
|
|
114
110
|
options[:optional] = true if parsed[:optional]
|
|
115
111
|
options[:multi] = true if parsed[:multi]
|
|
112
|
+
options[:nullable] = true if parsed[:nullable]
|
|
116
113
|
end
|
|
114
|
+
options[:nullable] = true if all_types.delete(:null)
|
|
115
|
+
# Unwrap single-element arrays: typelize field: ["string"] behaves like typelize field: "string"
|
|
116
|
+
options[:type] = (all_types.size == 1) ? all_types.first : all_types
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
instance_variable_get(instance_variable)[name.to_sym] ||= {}
|
data/lib/typelizer/generator.rb
CHANGED
|
@@ -9,37 +9,12 @@ module Typelizer
|
|
|
9
9
|
def call(force: false)
|
|
10
10
|
return [] unless Typelizer.enabled?
|
|
11
11
|
|
|
12
|
-
load_serializers
|
|
13
|
-
serializers = target_serializers
|
|
14
|
-
|
|
15
12
|
Typelizer.configuration.writers.each do |writer_name, writer_config|
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
interfaces = Typelizer.interfaces(writer_name: writer_name)
|
|
14
|
+
next if interfaces.empty?
|
|
18
15
|
|
|
19
16
|
Writer.new(writer_config).call(interfaces, force: force)
|
|
20
17
|
end
|
|
21
|
-
|
|
22
|
-
serializers
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
|
|
27
|
-
def load_serializers
|
|
28
|
-
Typelizer.dirs.flat_map { |dir| Dir["#{dir}/**/*.rb"] }.each do |file|
|
|
29
|
-
require file
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def target_serializers
|
|
34
|
-
base_classes = Typelizer.base_classes.filter_map do |base_class|
|
|
35
|
-
Object.const_get(base_class) if Object.const_defined?(base_class)
|
|
36
|
-
end.tap do |base_classes|
|
|
37
|
-
raise ArgumentError, "Please ensure all your serializers include Typelizer::DSL." if base_classes.none?
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
(base_classes + base_classes.flat_map(&:descendants)).uniq
|
|
41
|
-
.reject { |serializer| Typelizer.reject_class.call(serializer: serializer) }
|
|
42
|
-
.sort_by(&:name)
|
|
43
18
|
end
|
|
44
19
|
end
|
|
45
20
|
end
|
data/lib/typelizer/interface.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
require_relative "type_inference"
|
|
2
|
+
|
|
1
3
|
module Typelizer
|
|
2
4
|
class Interface
|
|
5
|
+
include TypeInference
|
|
6
|
+
|
|
3
7
|
attr_reader :serializer, :context
|
|
4
8
|
|
|
5
9
|
def initialize(serializer:, context:)
|
|
@@ -60,7 +64,7 @@ module Typelizer
|
|
|
60
64
|
|
|
61
65
|
def enum_types
|
|
62
66
|
@enum_types ||= begin
|
|
63
|
-
all_properties = properties + trait_interfaces.flat_map(&:properties)
|
|
67
|
+
all_properties = collect_all_properties(properties + trait_interfaces.flat_map(&:properties))
|
|
64
68
|
all_properties
|
|
65
69
|
.select(&:enum_definition)
|
|
66
70
|
.uniq(&:enum_type_name)
|
|
@@ -104,12 +108,12 @@ module Typelizer
|
|
|
104
108
|
|
|
105
109
|
def imports
|
|
106
110
|
@imports ||= begin
|
|
107
|
-
# Include both main properties and trait properties for import collection
|
|
108
|
-
|
|
111
|
+
# Include both main properties and trait properties for import collection,
|
|
112
|
+
# recursively including nested sub-properties
|
|
113
|
+
all_properties = collect_all_properties(properties_to_print + trait_interfaces.flat_map(&:properties))
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
.partition { |type| type.is_a?(Interface) }
|
|
115
|
+
flat_types = all_properties.filter_map(&:type).flat_map { |t| Array(t) }.uniq
|
|
116
|
+
association_serializers, attribute_types = flat_types.partition { |type| type.is_a?(Interface) }
|
|
113
117
|
|
|
114
118
|
serializer_types = association_serializers
|
|
115
119
|
.filter_map { |interface| interface.name if interface.name != name && !interface.inline? }
|
|
@@ -158,6 +162,16 @@ module Typelizer
|
|
|
158
162
|
|
|
159
163
|
private
|
|
160
164
|
|
|
165
|
+
def collect_all_properties(props)
|
|
166
|
+
props.flat_map do |prop|
|
|
167
|
+
if prop.nested_properties&.any?
|
|
168
|
+
[prop] + collect_all_properties(prop.nested_properties)
|
|
169
|
+
else
|
|
170
|
+
[prop]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
161
175
|
def self_type_name
|
|
162
176
|
serializer.name.match(/(\w+::)?(\w+)(Serializer|Resource)/)[2]
|
|
163
177
|
end
|
|
@@ -175,54 +189,63 @@ module Typelizer
|
|
|
175
189
|
multi_attrs = serializer.respond_to?(:_typelizer_multi_attributes) ? serializer._typelizer_multi_attributes : Set.new
|
|
176
190
|
|
|
177
191
|
props.map do |prop|
|
|
178
|
-
has_dsl = dsl_attrs
|
|
192
|
+
has_dsl = dsl_attrs_for(prop, dsl_attrs)&.any?
|
|
179
193
|
|
|
180
194
|
prop
|
|
181
195
|
.then { |p| apply_dsl_type(p, dsl_attrs) }
|
|
182
196
|
.then { |p| has_dsl ? p : apply_model_inference(p) }
|
|
183
197
|
.then { |p| apply_multi_flag(p, multi_attrs) }
|
|
184
198
|
.then { |p| apply_metadata(p) }
|
|
199
|
+
.then { |p| infer_nested_property_types(p) }
|
|
185
200
|
end
|
|
186
201
|
end
|
|
187
202
|
|
|
203
|
+
def dsl_attrs_for(prop, dsl_attrs)
|
|
204
|
+
dsl_attrs[prop.column_name.to_sym] || dsl_attrs[prop.name.to_sym]
|
|
205
|
+
end
|
|
206
|
+
|
|
188
207
|
def apply_dsl_type(prop, dsl_attrs)
|
|
189
|
-
dsl_type = dsl_attrs
|
|
208
|
+
dsl_type = dsl_attrs_for(prop, dsl_attrs)
|
|
190
209
|
return prop unless dsl_type&.any?
|
|
191
210
|
|
|
211
|
+
dsl_type = resolve_class_type(dsl_type)
|
|
192
212
|
prop.with(**dsl_type)
|
|
193
213
|
end
|
|
194
214
|
|
|
195
|
-
def
|
|
196
|
-
|
|
197
|
-
end
|
|
215
|
+
def resolve_class_type(attrs)
|
|
216
|
+
type = attrs[:type]
|
|
198
217
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
prop.tap do |p|
|
|
207
|
-
p.comment ||= model_plugin.comment_for(p) if config.comments && p.comment != false
|
|
208
|
-
p.enum ||= model_plugin.enum_for(p) if p.enum != false
|
|
218
|
+
case type
|
|
219
|
+
when Array
|
|
220
|
+
resolve_union_class_types(attrs)
|
|
221
|
+
when String, Symbol
|
|
222
|
+
resolve_single_class_type(attrs)
|
|
223
|
+
else
|
|
224
|
+
attrs
|
|
209
225
|
end
|
|
210
226
|
end
|
|
211
227
|
|
|
212
|
-
def
|
|
213
|
-
|
|
228
|
+
def resolve_single_class_type(attrs)
|
|
229
|
+
attrs.merge(type: resolve_type_part(attrs[:type]))
|
|
230
|
+
end
|
|
214
231
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
232
|
+
def resolve_union_class_types(attrs)
|
|
233
|
+
resolved = attrs[:type].map { |part| resolve_type_part(part) }
|
|
234
|
+
# Unwrap single-element arrays (e.g., after null extraction from ["Serializer", null])
|
|
235
|
+
attrs.merge(type: (resolved.size == 1) ? resolved.first : resolved)
|
|
236
|
+
end
|
|
220
237
|
|
|
221
|
-
|
|
238
|
+
def resolve_type_part(part)
|
|
239
|
+
klass = Object.const_get(part.to_s)
|
|
240
|
+
klass.respond_to?(:typelizer_config) ? context.interface_for(klass) : part
|
|
241
|
+
rescue NameError
|
|
242
|
+
part
|
|
222
243
|
end
|
|
223
244
|
|
|
224
|
-
def
|
|
225
|
-
|
|
245
|
+
def apply_multi_flag(prop, multi_attrs)
|
|
246
|
+
return prop unless multi_attrs.include?(prop.column_name.to_sym)
|
|
247
|
+
|
|
248
|
+
prop.with(multi: true)
|
|
226
249
|
end
|
|
227
250
|
end
|
|
228
251
|
end
|
|
@@ -12,6 +12,7 @@ module Typelizer
|
|
|
12
12
|
infer_types_for_association(prop) ||
|
|
13
13
|
infer_types_for_column(prop) ||
|
|
14
14
|
infer_types_for_association_ids(prop) ||
|
|
15
|
+
infer_types_for_delegate(prop) ||
|
|
15
16
|
infer_types_for_attribute(prop)
|
|
16
17
|
|
|
17
18
|
prop
|
|
@@ -66,6 +67,7 @@ module Typelizer
|
|
|
66
67
|
column = model_class&.columns_hash&.dig(prop.column_name.to_s)
|
|
67
68
|
return nil unless column
|
|
68
69
|
|
|
70
|
+
prop.column_type = column.type
|
|
69
71
|
prop.multi = !!column.try(:array)
|
|
70
72
|
case config.null_strategy
|
|
71
73
|
when :nullable
|
|
@@ -100,6 +102,25 @@ module Typelizer
|
|
|
100
102
|
prop
|
|
101
103
|
end
|
|
102
104
|
|
|
105
|
+
def infer_types_for_delegate(prop)
|
|
106
|
+
return nil unless model_class
|
|
107
|
+
|
|
108
|
+
info = DelegateTracker[model_class, prop.column_name.to_sym]
|
|
109
|
+
return nil unless info
|
|
110
|
+
|
|
111
|
+
assoc = model_class.reflect_on_association(info[:to])
|
|
112
|
+
return nil unless assoc
|
|
113
|
+
|
|
114
|
+
target = assoc.klass
|
|
115
|
+
col = target.columns_hash[info[:original].to_s]
|
|
116
|
+
return nil unless col
|
|
117
|
+
|
|
118
|
+
prop.type = @config.type_mapping[col.type]
|
|
119
|
+
prop.multi = !!col.try(:array)
|
|
120
|
+
prop.nullable = col.null || info[:allow_nil]
|
|
121
|
+
prop
|
|
122
|
+
end
|
|
123
|
+
|
|
103
124
|
def infer_types_for_attribute(prop)
|
|
104
125
|
return nil unless model_class.respond_to?(:attribute_types)
|
|
105
126
|
|
|
@@ -111,9 +132,11 @@ module Typelizer
|
|
|
111
132
|
end
|
|
112
133
|
|
|
113
134
|
if attribute_type_obj.respond_to?(:subtype)
|
|
135
|
+
prop.column_type = attribute_type_obj.subtype.type
|
|
114
136
|
prop.type = @config.type_mapping[attribute_type_obj.subtype.type]
|
|
115
137
|
prop.multi = true
|
|
116
138
|
elsif attribute_type_obj.respond_to?(:type)
|
|
139
|
+
prop.column_type = attribute_type_obj.type
|
|
117
140
|
prop.type = @config.type_mapping[attribute_type_obj.type]
|
|
118
141
|
end
|
|
119
142
|
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typelizer
|
|
4
|
+
module OpenAPI
|
|
5
|
+
SUPPORTED_VERSIONS = ["3.0", "3.1"].freeze
|
|
6
|
+
|
|
7
|
+
OPENAPI_TYPES = %i[integer number string boolean object array null].freeze
|
|
8
|
+
TS_OBJECT_TYPES = %w[any unknown never Record Partial Pick Omit].freeze
|
|
9
|
+
|
|
10
|
+
COLUMN_TYPE_MAP = {
|
|
11
|
+
integer: {type: :integer},
|
|
12
|
+
bigint: {type: :integer, format: :int64},
|
|
13
|
+
decimal: {type: :number, format: :double},
|
|
14
|
+
float: {type: :number, format: :float},
|
|
15
|
+
boolean: {type: :boolean},
|
|
16
|
+
string: {type: :string},
|
|
17
|
+
text: {type: :string},
|
|
18
|
+
citext: {type: :string},
|
|
19
|
+
uuid: {type: :string, format: :uuid},
|
|
20
|
+
date: {type: :string, format: :date},
|
|
21
|
+
datetime: {type: :string, format: :"date-time"},
|
|
22
|
+
time: {type: :string, format: :time},
|
|
23
|
+
json: {type: :object},
|
|
24
|
+
jsonb: {type: :object},
|
|
25
|
+
binary: {type: :string, format: :binary},
|
|
26
|
+
inet: {type: :string},
|
|
27
|
+
cidr: {type: :string}
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def schema_for(interface, openapi_version: "3.0")
|
|
32
|
+
validate_version!(openapi_version)
|
|
33
|
+
|
|
34
|
+
required_props = interface.properties.reject(&:optional).map(&:name)
|
|
35
|
+
schema = {
|
|
36
|
+
type: :object,
|
|
37
|
+
properties: interface.properties.to_h { |prop| [prop.name, property_schema(prop, openapi_version: openapi_version)] }
|
|
38
|
+
}
|
|
39
|
+
schema[:required] = required_props if required_props.any?
|
|
40
|
+
schema
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def property_schema(property, openapi_version: "3.0")
|
|
44
|
+
if property.type.is_a?(Array)
|
|
45
|
+
return union_schema(property, openapi_version: openapi_version)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
definition = base_type(property, openapi_version: openapi_version)
|
|
49
|
+
ref = definition.delete("$ref")
|
|
50
|
+
|
|
51
|
+
definition = if ref
|
|
52
|
+
ref_schema(ref, property, openapi_version: openapi_version)
|
|
53
|
+
else
|
|
54
|
+
inline_schema(definition, property, openapi_version: openapi_version)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
definition = wrap_traits(definition, property, openapi_version: openapi_version)
|
|
58
|
+
wrap_multi(definition, property, openapi_version: openapi_version)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def ref_schema(ref, property, openapi_version:)
|
|
64
|
+
ref_obj = {"$ref" => ref}
|
|
65
|
+
item_nullable = !property.multi && property.nullable
|
|
66
|
+
|
|
67
|
+
if v31?(openapi_version)
|
|
68
|
+
definition = item_nullable ? {oneOf: [ref_obj, {type: :null}]} : ref_obj
|
|
69
|
+
else
|
|
70
|
+
needs_wrapper = item_nullable || (!property.multi && (property.comment.is_a?(String) || property.deprecated))
|
|
71
|
+
definition = needs_wrapper ? {allOf: [ref_obj]} : ref_obj
|
|
72
|
+
definition[:nullable] = true if item_nullable
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
apply_metadata(definition, property) unless property.multi
|
|
76
|
+
definition
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def inline_schema(definition, property, openapi_version:)
|
|
80
|
+
unless property.multi
|
|
81
|
+
apply_nullable(definition, property, openapi_version: openapi_version)
|
|
82
|
+
apply_metadata(definition, property)
|
|
83
|
+
end
|
|
84
|
+
if property.enum.is_a?(Array)
|
|
85
|
+
items_nullable = !property.multi && property.nullable
|
|
86
|
+
definition[:enum] = (items_nullable && !property.enum.include?(nil)) ? property.enum + [nil] : property.enum
|
|
87
|
+
end
|
|
88
|
+
definition
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def union_schema(property, openapi_version:)
|
|
92
|
+
schemas = property.type.map { |part| union_member_schema(part) }
|
|
93
|
+
|
|
94
|
+
definition = {anyOf: schemas}
|
|
95
|
+
|
|
96
|
+
unless property.multi
|
|
97
|
+
apply_nullable(definition, property, openapi_version: openapi_version)
|
|
98
|
+
apply_metadata(definition, property)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
wrap_multi(definition, property, openapi_version: openapi_version)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def union_member_schema(type)
|
|
105
|
+
if type.respond_to?(:properties)
|
|
106
|
+
{"$ref" => "#/components/schemas/#{type.name}"}
|
|
107
|
+
else
|
|
108
|
+
sym = type.to_sym
|
|
109
|
+
if OPENAPI_TYPES.include?(sym)
|
|
110
|
+
{type: sym}
|
|
111
|
+
elsif ts_only_type?(type.to_s)
|
|
112
|
+
{type: :object}
|
|
113
|
+
else
|
|
114
|
+
{"$ref" => "#/components/schemas/#{type}"}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def wrap_traits(definition, property, openapi_version:)
|
|
120
|
+
return definition unless property.respond_to?(:with_traits) && property.with_traits&.any? && property.type.respond_to?(:name)
|
|
121
|
+
|
|
122
|
+
trait_refs = property.with_traits.map do |t|
|
|
123
|
+
{"$ref" => "#/components/schemas/#{property.type.name}#{t.to_s.camelize}Trait"}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
base_ref = definition.delete("$ref")
|
|
127
|
+
if base_ref
|
|
128
|
+
definition = {allOf: [{"$ref" => base_ref}] + trait_refs}
|
|
129
|
+
elsif definition[:oneOf]
|
|
130
|
+
non_null = definition[:oneOf].reject { |s| s[:type] == :null }
|
|
131
|
+
null_schemas = definition[:oneOf].select { |s| s[:type] == :null }
|
|
132
|
+
all_of = non_null + trait_refs
|
|
133
|
+
definition = null_schemas.any? ? {oneOf: [{allOf: all_of}, *null_schemas]} : {allOf: all_of}
|
|
134
|
+
elsif definition[:allOf]
|
|
135
|
+
definition[:allOf].concat(trait_refs)
|
|
136
|
+
else
|
|
137
|
+
raise ArgumentError, "Unexpected schema shape for traits on property #{property.name}: #{definition.inspect}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
definition[:nullable] = true if !v31?(openapi_version) && property.nullable
|
|
141
|
+
definition
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def apply_metadata(definition, property)
|
|
145
|
+
definition[:description] = property.comment if property.comment.is_a?(String)
|
|
146
|
+
definition[:deprecated] = true if property.deprecated
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def apply_nullable(definition, property, openapi_version:)
|
|
150
|
+
return unless property.nullable
|
|
151
|
+
|
|
152
|
+
if definition[:anyOf]
|
|
153
|
+
v31?(openapi_version) ? definition[:anyOf] << {type: :null} : definition[:nullable] = true
|
|
154
|
+
elsif definition[:type]
|
|
155
|
+
v31?(openapi_version) ? definition[:type] = [definition[:type], :null] : definition[:nullable] = true
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def wrap_multi(definition, property, openapi_version:)
|
|
160
|
+
return definition unless property.multi
|
|
161
|
+
|
|
162
|
+
definition = {type: :array, items: definition}
|
|
163
|
+
apply_metadata(definition, property)
|
|
164
|
+
if property.nullable
|
|
165
|
+
v31?(openapi_version) ? definition[:type] = [:array, :null] : definition[:nullable] = true
|
|
166
|
+
end
|
|
167
|
+
definition
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def base_type(property, openapi_version:)
|
|
171
|
+
if property.type.respond_to?(:properties)
|
|
172
|
+
if property.type.respond_to?(:inline?) && property.type.inline?
|
|
173
|
+
schema_for(property.type, openapi_version: openapi_version)
|
|
174
|
+
else
|
|
175
|
+
{"$ref" => "#/components/schemas/#{property.type.name}"}
|
|
176
|
+
end
|
|
177
|
+
elsif property.type.nil? && property.respond_to?(:nested_properties) && property.nested_properties&.any?
|
|
178
|
+
nested_schema(property, openapi_version: openapi_version)
|
|
179
|
+
elsif property.column_type && COLUMN_TYPE_MAP.key?(property.column_type)
|
|
180
|
+
result = COLUMN_TYPE_MAP[property.column_type].dup
|
|
181
|
+
result[:type] = :string if property.enum
|
|
182
|
+
result
|
|
183
|
+
elsif (property.type.is_a?(String) || property.type.is_a?(Symbol)) && !OPENAPI_TYPES.include?(property.type.to_sym) && !ts_only_type?(property.type.to_s)
|
|
184
|
+
{"$ref" => "#/components/schemas/#{property.type}"}
|
|
185
|
+
else
|
|
186
|
+
type = property.type.to_s.to_sym
|
|
187
|
+
OPENAPI_TYPES.include?(type) ? {type: type} : {type: :object}
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def nested_schema(property, openapi_version:)
|
|
192
|
+
required = property.nested_properties.reject(&:optional).map(&:name)
|
|
193
|
+
schema = {
|
|
194
|
+
type: :object,
|
|
195
|
+
properties: property.nested_properties.to_h { |p| [p.name, property_schema(p, openapi_version: openapi_version)] }
|
|
196
|
+
}
|
|
197
|
+
schema[:required] = required if required.any?
|
|
198
|
+
schema
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def v31?(openapi_version)
|
|
202
|
+
openapi_version.to_s == "3.1"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def ts_only_type?(type_str)
|
|
206
|
+
type_str.start_with?("{") || type_str.include?("<") || TS_OBJECT_TYPES.include?(type_str)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def validate_version!(openapi_version)
|
|
210
|
+
raise ArgumentError, "Unsupported openapi_version: #{openapi_version}. Must be one of: #{SUPPORTED_VERSIONS.join(", ")}" unless SUPPORTED_VERSIONS.include?(openapi_version.to_s)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
data/lib/typelizer/property.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module Typelizer
|
|
2
2
|
Property = Struct.new(
|
|
3
3
|
:name, :type, :optional, :nullable,
|
|
4
|
-
:multi, :column_name, :comment, :enum, :enum_type_name, :deprecated,
|
|
5
|
-
:with_traits,
|
|
4
|
+
:multi, :column_name, :column_type, :comment, :enum, :enum_type_name, :deprecated,
|
|
5
|
+
:with_traits, :nested_properties, :nested_typelizes,
|
|
6
6
|
keyword_init: true
|
|
7
7
|
) do
|
|
8
8
|
def with(**attrs)
|
|
@@ -52,7 +52,13 @@ module Typelizer
|
|
|
52
52
|
def fingerprint
|
|
53
53
|
# Use array format for consistent output across Ruby versions
|
|
54
54
|
# (Hash#inspect format changed in Ruby 3.4)
|
|
55
|
-
|
|
55
|
+
# Exclude fields that do not affect generated TypeScript output.
|
|
56
|
+
# Exclude nested_properties/nested_typelizes from to_h to avoid changing
|
|
57
|
+
# fingerprints for properties that don't use them.
|
|
58
|
+
# nested_typelizes is excluded entirely as it only affects inference, not output.
|
|
59
|
+
to_h.except(:column_type, :nested_properties, :nested_typelizes)
|
|
60
|
+
.merge(type: UnionTypeSorter.sort(type_name(sort_order: :alphabetical), :alphabetical))
|
|
61
|
+
.then { |h| nested_properties&.any? ? h.merge(nested_properties: nested_properties.map(&:fingerprint)) : h }
|
|
56
62
|
.to_a.inspect
|
|
57
63
|
end
|
|
58
64
|
|
|
@@ -89,7 +95,20 @@ module Typelizer
|
|
|
89
95
|
return enum_values.join(" | ")
|
|
90
96
|
end
|
|
91
97
|
|
|
92
|
-
type.
|
|
98
|
+
if type.nil? && nested_properties&.any?
|
|
99
|
+
inner = nested_properties.map { |p|
|
|
100
|
+
rendered = p.render(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes) + ";"
|
|
101
|
+
rendered.gsub(/^/, " ")
|
|
102
|
+
}.join("\n")
|
|
103
|
+
return "{\n#{inner}\n}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
case type
|
|
107
|
+
when Array
|
|
108
|
+
type.map { |t| t.respond_to?(:name) ? t.name : t.to_s }.join(" | ")
|
|
109
|
+
else
|
|
110
|
+
type.respond_to?(:name) ? type.name : type&.to_s || "unknown"
|
|
111
|
+
end
|
|
93
112
|
end
|
|
94
113
|
end
|
|
95
114
|
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Typelizer
|
|
4
4
|
module SerializerPlugins
|
|
5
|
-
class Alba::
|
|
5
|
+
class Alba::BlockAttributeCollector
|
|
6
6
|
attr_reader :collected_attributes, :collected_typelizes
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
@@ -53,15 +53,26 @@ module Typelizer
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
-
# Simple struct to hold association info from
|
|
57
|
-
|
|
56
|
+
# Simple struct to hold association info from blocks
|
|
57
|
+
BlockAssociation = Struct.new(:name, :resource, :with_traits, :multi, :key, keyword_init: true)
|
|
58
|
+
|
|
59
|
+
# Struct to hold nested attribute info captured within a block
|
|
60
|
+
BlockNestedAttribute = Struct.new(:name, :block, keyword_init: true)
|
|
61
|
+
|
|
62
|
+
def nested_attribute(name, **options, &block)
|
|
63
|
+
raise ArgumentError, "Block is required for nested_attribute" unless block
|
|
64
|
+
|
|
65
|
+
@collected_attributes[name] = BlockNestedAttribute.new(name: name, block: block)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
alias_method :nested, :nested_attribute
|
|
58
69
|
|
|
59
70
|
# Support association methods that might be used in traits
|
|
60
71
|
def one(name, **options, &block)
|
|
61
72
|
resource = options[:resource] || options[:serializer]
|
|
62
73
|
with_traits = options[:with_traits]
|
|
63
74
|
key = options[:key] || name
|
|
64
|
-
@collected_attributes[key] =
|
|
75
|
+
@collected_attributes[key] = BlockAssociation.new(
|
|
65
76
|
name: name,
|
|
66
77
|
resource: resource,
|
|
67
78
|
with_traits: with_traits,
|
|
@@ -77,7 +88,7 @@ module Typelizer
|
|
|
77
88
|
resource = options[:resource] || options[:serializer]
|
|
78
89
|
with_traits = options[:with_traits]
|
|
79
90
|
key = options[:key] || name
|
|
80
|
-
@collected_attributes[key] =
|
|
91
|
+
@collected_attributes[key] = BlockAssociation.new(
|
|
81
92
|
name: name,
|
|
82
93
|
resource: resource,
|
|
83
94
|
with_traits: with_traits,
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../../type_inference"
|
|
4
|
+
|
|
3
5
|
module Typelizer
|
|
4
6
|
module SerializerPlugins
|
|
5
7
|
class Alba::TraitInterface
|
|
8
|
+
include TypeInference
|
|
9
|
+
|
|
6
10
|
attr_reader :serializer, :trait_name, :context, :plugin
|
|
7
11
|
|
|
8
12
|
def initialize(serializer:, trait_name:, context:, plugin:)
|
|
@@ -33,31 +37,13 @@ module Typelizer
|
|
|
33
37
|
|
|
34
38
|
def infer_types(props, typelizes)
|
|
35
39
|
props.map do |prop|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
property.enum ||= model_plugin.enum_for(property) if property.enum != false
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Fall back to model plugin for type inference
|
|
46
|
-
model_plugin.infer_types(prop)
|
|
40
|
+
dsl_type = typelizes[prop.column_name.to_sym] || typelizes[prop.name.to_sym]
|
|
41
|
+
prop
|
|
42
|
+
.then { |p| dsl_type&.any? ? p.with(**dsl_type) : apply_model_inference(p) }
|
|
43
|
+
.then { |p| apply_metadata(p) }
|
|
44
|
+
.then { |p| infer_nested_property_types(p) }
|
|
47
45
|
end
|
|
48
46
|
end
|
|
49
|
-
|
|
50
|
-
def model_class
|
|
51
|
-
return serializer._typelizer_model_name if serializer.respond_to?(:_typelizer_model_name)
|
|
52
|
-
|
|
53
|
-
config.instance_exec(serializer, &config.serializer_model_mapper)
|
|
54
|
-
rescue NameError
|
|
55
|
-
nil
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def model_plugin
|
|
59
|
-
@model_plugin ||= config.model_plugin.new(model_class: model_class, config: config)
|
|
60
|
-
end
|
|
61
47
|
end
|
|
62
48
|
end
|
|
63
49
|
end
|
|
@@ -48,19 +48,19 @@ module Typelizer
|
|
|
48
48
|
return [], {} unless trait_block
|
|
49
49
|
|
|
50
50
|
# Create a collector to capture attributes defined in the trait block
|
|
51
|
-
collector =
|
|
51
|
+
collector = BlockAttributeCollector.new
|
|
52
52
|
collector.instance_exec(&trait_block)
|
|
53
53
|
|
|
54
54
|
props = collector.collected_attributes.map do |name, attr|
|
|
55
|
-
|
|
55
|
+
build_collected_property(name.is_a?(Symbol) ? name.name : name, attr)
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
[props, collector.collected_typelizes]
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
def
|
|
61
|
+
def build_collected_property(name, attr)
|
|
62
62
|
case attr
|
|
63
|
-
when
|
|
63
|
+
when BlockAttributeCollector::BlockAssociation
|
|
64
64
|
with_traits = Array(attr.with_traits) if attr.with_traits
|
|
65
65
|
resource = attr.resource || infer_resource_from_name(name)
|
|
66
66
|
|
|
@@ -73,6 +73,19 @@ module Typelizer
|
|
|
73
73
|
column_name: name,
|
|
74
74
|
with_traits: with_traits
|
|
75
75
|
)
|
|
76
|
+
when BlockAttributeCollector::BlockNestedAttribute
|
|
77
|
+
prop_name = has_transform_key?(serializer) ? fetch_key(serializer, name) : name
|
|
78
|
+
nested_props, nested_typelizes = collect_nested_block(attr.block)
|
|
79
|
+
Property.new(
|
|
80
|
+
name: prop_name,
|
|
81
|
+
type: nil,
|
|
82
|
+
optional: false,
|
|
83
|
+
nullable: false,
|
|
84
|
+
multi: false,
|
|
85
|
+
column_name: name,
|
|
86
|
+
nested_properties: nested_props,
|
|
87
|
+
nested_typelizes: nested_typelizes
|
|
88
|
+
)
|
|
76
89
|
else
|
|
77
90
|
build_property(name, attr)
|
|
78
91
|
end
|
|
@@ -95,7 +108,7 @@ module Typelizer
|
|
|
95
108
|
end
|
|
96
109
|
|
|
97
110
|
def trait_interfaces
|
|
98
|
-
traits.map do |trait_name, _|
|
|
111
|
+
@trait_interfaces ||= traits.map do |trait_name, _|
|
|
99
112
|
TraitInterface.new(
|
|
100
113
|
serializer: serializer,
|
|
101
114
|
trait_name: trait_name,
|
|
@@ -164,6 +177,8 @@ module Typelizer
|
|
|
164
177
|
**options
|
|
165
178
|
)
|
|
166
179
|
when ::Alba::NestedAttribute
|
|
180
|
+
block = attr.instance_variable_get(:@block)
|
|
181
|
+
nested_props, nested_typelizes = collect_nested_block(block)
|
|
167
182
|
Property.new(
|
|
168
183
|
name: name,
|
|
169
184
|
type: nil,
|
|
@@ -171,6 +186,8 @@ module Typelizer
|
|
|
171
186
|
nullable: false,
|
|
172
187
|
multi: false,
|
|
173
188
|
column_name: column_name,
|
|
189
|
+
nested_properties: nested_props,
|
|
190
|
+
nested_typelizes: nested_typelizes,
|
|
174
191
|
**options
|
|
175
192
|
)
|
|
176
193
|
when ::Alba::ConditionalAttribute
|
|
@@ -192,14 +209,24 @@ module Typelizer
|
|
|
192
209
|
::Alba.transform_key(key, transform_type: serializer._transform_type)
|
|
193
210
|
end
|
|
194
211
|
|
|
195
|
-
private
|
|
196
|
-
|
|
197
212
|
def ts_mapper
|
|
198
213
|
config.plugin_configs.dig(:alba, :ts_mapper) || ALBA_TS_MAPPER
|
|
199
214
|
end
|
|
215
|
+
|
|
216
|
+
def collect_nested_block(block)
|
|
217
|
+
collector = BlockAttributeCollector.new
|
|
218
|
+
collector.instance_exec(&block)
|
|
219
|
+
|
|
220
|
+
props = collector.collected_attributes.map do |attr_name, attr|
|
|
221
|
+
attr_name_str = attr_name.is_a?(Symbol) ? attr_name.name : attr_name
|
|
222
|
+
build_collected_property(attr_name_str, attr)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
[props, collector.collected_typelizes]
|
|
226
|
+
end
|
|
200
227
|
end
|
|
201
228
|
end
|
|
202
229
|
end
|
|
203
230
|
|
|
204
|
-
require_relative "alba/
|
|
231
|
+
require_relative "alba/block_attribute_collector"
|
|
205
232
|
require_relative "alba/trait_interface"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Typelizer
|
|
4
|
+
module TypeInference
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def apply_model_inference(prop)
|
|
8
|
+
model_plugin.infer_types(prop)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def apply_metadata(prop)
|
|
12
|
+
prop.tap do |p|
|
|
13
|
+
p.comment ||= model_plugin.comment_for(p) if config.comments && p.comment != false
|
|
14
|
+
p.enum ||= model_plugin.enum_for(p) if p.enum != false
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def infer_nested_property_types(prop)
|
|
19
|
+
return prop unless prop.nested_properties&.any?
|
|
20
|
+
|
|
21
|
+
typelizes = prop.nested_typelizes || {}
|
|
22
|
+
inferred = prop.nested_properties.map do |sub_prop|
|
|
23
|
+
dsl_type = typelizes[sub_prop.column_name.to_sym] || typelizes[sub_prop.name.to_sym]
|
|
24
|
+
sub_prop
|
|
25
|
+
.then { |p| dsl_type&.any? ? p.with(**dsl_type) : apply_model_inference(p) }
|
|
26
|
+
.then { |p| apply_metadata(p) }
|
|
27
|
+
.then { |p| infer_nested_property_types(p) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
prop.with(nested_properties: inferred)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def model_class
|
|
34
|
+
return serializer._typelizer_model_name if serializer.respond_to?(:_typelizer_model_name)
|
|
35
|
+
|
|
36
|
+
config.instance_exec(serializer, &config.serializer_model_mapper)
|
|
37
|
+
rescue NameError => e
|
|
38
|
+
Typelizer.logger.debug("model_mapper failed for serializer #{serializer.name}: #{e.class}: #{e.message}")
|
|
39
|
+
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def model_plugin
|
|
44
|
+
@model_plugin ||= config.model_plugin.new(model_class: model_class, config: config)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -14,6 +14,8 @@ module Typelizer
|
|
|
14
14
|
return options if type_def.nil?
|
|
15
15
|
|
|
16
16
|
type_str = type_def.to_s
|
|
17
|
+
return parse_union(type_str, **options) if type_str.include?("|")
|
|
18
|
+
|
|
17
19
|
match = TYPE_PATTERN.match(type_str)
|
|
18
20
|
|
|
19
21
|
return {type: type_def}.merge(options) unless match
|
|
@@ -34,6 +36,18 @@ module Typelizer
|
|
|
34
36
|
type_str = type_def.to_s
|
|
35
37
|
type_str.end_with?("?", "[]")
|
|
36
38
|
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def parse_union(type_str, **options)
|
|
43
|
+
parts = type_str.split(/\s*\|\s*/)
|
|
44
|
+
options[:nullable] = true if parts.delete("null")
|
|
45
|
+
if parts.size == 1
|
|
46
|
+
parse(parts.first, **options)
|
|
47
|
+
else
|
|
48
|
+
{type: parts.map(&:to_sym)}.merge(options)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
37
51
|
end
|
|
38
52
|
end
|
|
39
53
|
end
|
data/lib/typelizer/version.rb
CHANGED
data/lib/typelizer.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative "typelizer/import_sorter"
|
|
|
16
16
|
require_relative "typelizer/interface"
|
|
17
17
|
require_relative "typelizer/renderer"
|
|
18
18
|
require_relative "typelizer/writer"
|
|
19
|
+
require_relative "typelizer/openapi"
|
|
19
20
|
require_relative "typelizer/generator"
|
|
20
21
|
require_relative "typelizer/type_parser"
|
|
21
22
|
require_relative "typelizer/dsl"
|
|
@@ -62,8 +63,45 @@ module Typelizer
|
|
|
62
63
|
yield configuration
|
|
63
64
|
end
|
|
64
65
|
|
|
66
|
+
def interfaces(writer_name: nil)
|
|
67
|
+
load_serializers
|
|
68
|
+
context = WriterContext.new(writer_name: writer_name)
|
|
69
|
+
target_serializers(context.writer_config.reject_class)
|
|
70
|
+
.map { |klass| context.interface_for(klass) }
|
|
71
|
+
.reject(&:empty?)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def openapi_schemas(writer_name: nil, openapi_version: "3.0")
|
|
75
|
+
result = {}
|
|
76
|
+
interfaces(writer_name: writer_name).each do |i|
|
|
77
|
+
result[i.name] = OpenAPI.schema_for(i, openapi_version: openapi_version)
|
|
78
|
+
i.trait_interfaces.each do |trait|
|
|
79
|
+
result[trait.name] = OpenAPI.schema_for(trait, openapi_version: openapi_version)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
result
|
|
83
|
+
end
|
|
84
|
+
|
|
65
85
|
private
|
|
66
86
|
|
|
87
|
+
def load_serializers
|
|
88
|
+
dirs.flat_map { |dir| Dir["#{dir}/**/*.rb"] }.each { |file| require file }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def target_serializers(reject_class)
|
|
92
|
+
resolved = base_classes.filter_map do |base_class|
|
|
93
|
+
Object.const_get(base_class) if Object.const_defined?(base_class)
|
|
94
|
+
end
|
|
95
|
+
if base_classes.any? && resolved.none?
|
|
96
|
+
logger.warn("Typelizer: No serializers found. Ensure your serializers include Typelizer::DSL.")
|
|
97
|
+
return []
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
(resolved + resolved.flat_map(&:descendants)).uniq
|
|
101
|
+
.reject { |serializer| reject_class.call(serializer: serializer) }
|
|
102
|
+
.sort_by(&:name)
|
|
103
|
+
end
|
|
104
|
+
|
|
67
105
|
attr_writer :base_classes
|
|
68
106
|
end
|
|
69
107
|
|
|
@@ -72,3 +110,5 @@ module Typelizer
|
|
|
72
110
|
|
|
73
111
|
self.base_classes = Set.new
|
|
74
112
|
end
|
|
113
|
+
|
|
114
|
+
require_relative "typelizer/delegate_tracker"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: typelizer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Svyatoslav Kryukov
|
|
@@ -51,6 +51,7 @@ files:
|
|
|
51
51
|
- lib/typelizer/config.rb
|
|
52
52
|
- lib/typelizer/configuration.rb
|
|
53
53
|
- lib/typelizer/contexts/writer_context.rb
|
|
54
|
+
- lib/typelizer/delegate_tracker.rb
|
|
54
55
|
- lib/typelizer/dsl.rb
|
|
55
56
|
- lib/typelizer/dsl/hooks.rb
|
|
56
57
|
- lib/typelizer/dsl/hooks/alba.rb
|
|
@@ -64,13 +65,14 @@ files:
|
|
|
64
65
|
- lib/typelizer/model_plugins/active_record.rb
|
|
65
66
|
- lib/typelizer/model_plugins/auto.rb
|
|
66
67
|
- lib/typelizer/model_plugins/poro.rb
|
|
68
|
+
- lib/typelizer/openapi.rb
|
|
67
69
|
- lib/typelizer/property.rb
|
|
68
70
|
- lib/typelizer/property_sorter.rb
|
|
69
71
|
- lib/typelizer/railtie.rb
|
|
70
72
|
- lib/typelizer/renderer.rb
|
|
71
73
|
- lib/typelizer/serializer_config_layer.rb
|
|
72
74
|
- lib/typelizer/serializer_plugins/alba.rb
|
|
73
|
-
- lib/typelizer/serializer_plugins/alba/
|
|
75
|
+
- lib/typelizer/serializer_plugins/alba/block_attribute_collector.rb
|
|
74
76
|
- lib/typelizer/serializer_plugins/alba/trait_interface.rb
|
|
75
77
|
- lib/typelizer/serializer_plugins/ams.rb
|
|
76
78
|
- lib/typelizer/serializer_plugins/auto.rb
|
|
@@ -84,6 +86,7 @@ files:
|
|
|
84
86
|
- lib/typelizer/templates/inheritance.ts.erb
|
|
85
87
|
- lib/typelizer/templates/inline_type.ts.erb
|
|
86
88
|
- lib/typelizer/templates/interface.ts.erb
|
|
89
|
+
- lib/typelizer/type_inference.rb
|
|
87
90
|
- lib/typelizer/type_parser.rb
|
|
88
91
|
- lib/typelizer/union_type_sorter.rb
|
|
89
92
|
- lib/typelizer/version.rb
|