jsonapi-resources-anchor 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: 10487e8f527e3c65ae349ed70be50c6cfc7ef15791ec9e46197b4ffca184ac63
4
+ data.tar.gz: c66ceb8ee261a5230d580eda504010472a14dc6046054da32b5394b2fec223c4
5
+ SHA512:
6
+ metadata.gz: 0d952dc77d9371b3bc8fc252680056c08442ed316509ad2f159dfd38312b7dd7b97481626769ba30e8c35aa8a08de762ad7061d8d6ba265258c591934ed6e02e
7
+ data.tar.gz: 2a427fa18bb7aa97fce4f45f78a276e5784e70e2222c33002f9069950b826231dbb6072c26381abf1f55c3a591e92a871f8f95f35a2be4b9e3d4c5f11a496249
@@ -0,0 +1,11 @@
1
+ module Anchor
2
+ class << self
3
+ def config
4
+ @config ||= Anchor::Config.new
5
+ end
6
+
7
+ def configure
8
+ yield(config)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,40 @@
1
+ module Anchor
2
+ module Annotatable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class << self
7
+ attr_reader :anchor_attributes,
8
+ :anchor_relationships,
9
+ :anchor_attributes_descriptions,
10
+ :anchor_relationships_descriptions
11
+
12
+ # @param name [String, Symbol]
13
+ # @param annotation_or_options [Anchor::Types, Hash, NilClass]
14
+ # @param options [Hash]
15
+ def attribute(name, annotation_or_options = nil, options = {})
16
+ @anchor_attributes ||= {}
17
+ @anchor_attributes_descriptions ||= {}
18
+ opts = annotation_or_options.is_a?(Hash) ? annotation_or_options : options
19
+ annotation_given = !(annotation_or_options.is_a?(Hash) || annotation_or_options.nil?)
20
+ @anchor_attributes[name] = annotation_or_options if annotation_given
21
+ @anchor_attributes_descriptions[name] = opts[:description] if opts[:description]
22
+ super(name, opts)
23
+ end
24
+
25
+ # @param name [String, Symbol]
26
+ # @param annotation_or_options [Anchor::Types::Relationship, Hash, NilClass]
27
+ # @param options [Hash]
28
+ def relationship(name, annotation_or_options = nil, options = {})
29
+ @anchor_relationships ||= {}
30
+ @anchor_relationships_descriptions ||= {}
31
+ opts = annotation_or_options.is_a?(Hash) ? annotation_or_options : options
32
+ annotation_given = !(annotation_or_options.is_a?(Hash) || annotation_or_options.nil?)
33
+ @anchor_relationships[name] = annotation_or_options if annotation_given
34
+ @anchor_relationships_descriptions[name] = opts[:description] if opts[:description]
35
+ super(name, opts)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,14 @@
1
+ module Anchor
2
+ module CustomLinkable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class << self
7
+ # @param type [Anchor::Types]
8
+ def anchor_links_schema(type = nil)
9
+ @anchor_links_schema ||= type
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ module Anchor
2
+ module CustomMeta
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class << self
7
+ # @param type [Anchor::Types]
8
+ def anchor_meta_schema(type = nil)
9
+ @anchor_meta_schema ||= type
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ module Anchor
2
+ module SchemaSerializable
3
+ extend ActiveSupport::Concern
4
+ include Anchor::TypeInferable
5
+ include Anchor::StaticContext
6
+ include Anchor::Annotatable
7
+ include Anchor::CustomLinkable
8
+ include Anchor::CustomMeta
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ module Anchor
2
+ module StaticContext
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class << self
7
+ def anchor_fetchable_fields(context)
8
+ fields
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,39 @@
1
+ module Anchor
2
+ module TypeInferable
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ class << self
7
+ attr_reader :anchor_method_added_count
8
+
9
+ # @param name [String] The type identifier to be used in the schema.
10
+ # @return [String]
11
+ def anchor_schema_name(name = nil)
12
+ @anchor_schema_name ||= name || anchor_default_schema_name
13
+ end
14
+
15
+ private
16
+
17
+ # @anchor_method_added_count[attribute] > 1 implies the attribute
18
+ # is computed via an instance method defined on the JSONAPI::Resource.
19
+ # `JSONAPI::Resource.attribute(:name, options)` adds #name to the resource.
20
+ # A user defined #name on the resource _also_ adds #name.
21
+ def method_added(method_name)
22
+ @anchor_method_added_count ||= Hash.new(0)
23
+ @anchor_method_added_count[method_name] += 1
24
+ super(method_name)
25
+ end
26
+
27
+ # inspiration from https://github.com/rmosolgo/graphql-ruby/blob/eda9b3d62b9e507787e590f0f179ec9d6956255a/lib/graphql/schema/member/base_dsl_methods.rb?plain=1#L102
28
+ def anchor_default_schema_name
29
+ # https://github.com/cerebris/jsonapi-resources/blob/d3c094b46a38650e583f40adc86474827b606fc7/lib/jsonapi/resource_common.rb?plain=1#L506
30
+ # https://github.com/cerebris/jsonapi-resources/blob/d3c094b46a38650e583f40adc86474827b606fc7/lib/jsonapi/resource_common.rb?plain=1#L564
31
+ return _type.to_s.classify if Anchor.config.use_type_as_schema_name
32
+
33
+ s_name = name.split("::").last
34
+ s_name.end_with?("Resource") ? s_name.sub(/Resource\Z/, "") : s_name
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,21 @@
1
+ module Anchor
2
+ class Config
3
+ attr_accessor :ar_column_to_type,
4
+ :field_case,
5
+ :use_active_record_validations,
6
+ :use_active_record_comment,
7
+ :infer_nullable_relationships_as_optional,
8
+ :empty_relationship_type,
9
+ :use_type_as_schema_name
10
+
11
+ def initialize
12
+ @ar_column_to_type = nil
13
+ @field_case = nil
14
+ @use_active_record_validations = true
15
+ @use_active_record_comment = nil
16
+ @infer_nullable_relationships_as_optional = nil
17
+ @empty_relationship_type = nil
18
+ @use_type_as_schema_name = nil
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,14 @@
1
+ module Anchor::JSONSchema
2
+ class Resource < Anchor::Resource
3
+ def express(context: {}, include_all_fields:, exclude_fields:)
4
+ included_fields = schema_fetchable_fields(context:, include_all_fields:)
5
+ included_fields -= exclude_fields if exclude_fields
6
+
7
+ properties = [id_property, type_property] +
8
+ Array.wrap(anchor_attributes_properties(included_fields:)) +
9
+ Array.wrap(anchor_relationships_property(included_fields:))
10
+
11
+ Anchor::Types::Object.new(properties)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,50 @@
1
+ module Anchor::JSONSchema
2
+ class SchemaGenerator < Anchor::SchemaGenerator
3
+ delegate :type_property, to: Anchor::JSONSchema::Serializer
4
+
5
+ def initialize(register:, context: {}, include_all_fields: false, exclude_fields: nil) # rubocop:disable Lint/MissingSuper
6
+ @register = register
7
+ @context = context
8
+ @include_all_fields = include_all_fields
9
+ @exclude_fields = exclude_fields
10
+ end
11
+
12
+ def call
13
+ result = {
14
+ "$schema" => "https://json-schema.org/draft-07/schema",
15
+ title: "Schema",
16
+ }
17
+ result.merge!(type_property(root_object))
18
+ result["$defs"] = definitions
19
+ result.to_json
20
+ end
21
+
22
+ private
23
+
24
+ def resources
25
+ @resources ||= @register.resources.map { |r| Anchor::JSONSchema::Resource.new(r) }
26
+ end
27
+
28
+ # @return [Anchor::Types::Object]
29
+ def root_object
30
+ properties = resources.map do |resource|
31
+ Types::Property.new(resource.anchor_schema_name.underscore, Types::Reference.new(resource.anchor_schema_name))
32
+ end
33
+
34
+ Types::Object.new(properties)
35
+ end
36
+
37
+ # @return [Hash{Symbol, String => Anchor::Types}]
38
+ def definitions
39
+ resources.map do |resource|
40
+ {
41
+ resource.anchor_schema_name => type_property(resource.express(
42
+ context: @context,
43
+ include_all_fields: @include_all_fields,
44
+ exclude_fields: @exclude_fields.nil? ? [] : @exclude_fields[r.anchor_schema_name.to_sym],
45
+ )),
46
+ }
47
+ end.reduce(&:merge)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,40 @@
1
+ module Anchor::JSONSchema
2
+ class Serializer
3
+ class << self
4
+ def type_property(type)
5
+ case type
6
+ when Anchor::Types::String.singleton_class then { type: "string" }
7
+ when Anchor::Types::BigDecimal.singleton_class then { type: "string" }
8
+ when Anchor::Types::Float.singleton_class then { type: "number" }
9
+ when Anchor::Types::Integer.singleton_class then { type: "number" }
10
+ when Anchor::Types::Boolean.singleton_class then { type: "boolean" }
11
+ when Anchor::Types::Null.singleton_class then { type: "null" }
12
+ when Anchor::Types::Record, Anchor::Types::Record.singleton_class then {
13
+ type: "object",
14
+ additionalProperties: "true",
15
+ }
16
+ when Anchor::Types::Union then { oneOf: type.types.map { |type| type_property(type) } }
17
+ when Anchor::Types::Maybe then type_property(Anchor::Types::Union.new([type.type, Anchor::Types::Null]))
18
+ when Anchor::Types::Array then { type: "array", items: type_property(type.type) }
19
+ when Anchor::Types::Literal then { enum: [type.value] }
20
+ when Anchor::Types::Reference then { "$ref" => "#/$defs/#{type.name}" }
21
+ when Anchor::Types::Object, Anchor::Types::Object.singleton_class then serialize_object(type)
22
+ when Anchor::Types::Enum.singleton_class then { enum: type.values.map(&:second) }
23
+ when Anchor::Types::Unknown.singleton_class then {}
24
+ else raise RuntimeError
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def serialize_object(type)
31
+ {
32
+ type: "object",
33
+ properties: type.properties.map { |p| { p.name => type_property(p.type) } }.reduce(&:merge),
34
+ required: type.properties.reject(&:optional).map(&:name),
35
+ additionalProperties: false,
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,188 @@
1
+ module Anchor
2
+ class Resource
3
+ delegate_missing_to :@resource_klass
4
+ attr_reader :resource_klass
5
+
6
+ # resource_klass#anchor_attributes, #anchor_relationships, #anchor_attributes_descriptions,
7
+ # #anchor_relationships_descriptions are optional methods from Anchor::Annotatable.
8
+ # @param [JSONAPI::Resource] Must include Anchor::TypeInferable
9
+ def initialize(resource_klass)
10
+ @resource_klass = resource_klass
11
+ @anchor_attributes = resource_klass.try(:anchor_attributes) || {}
12
+ @anchor_relationships = resource_klass.try(:anchor_relationships) || {}
13
+ @anchor_attributes_descriptions = resource_klass.try(:anchor_attributes_descriptions) || {}
14
+ @anchor_relationships_descriptions = resource_klass.try(:anchor_relationships_descriptions) || {}
15
+ @anchor_method_added_count = resource_klass.anchor_method_added_count || Hash.new(0)
16
+ @anchor_links_schema = resource_klass.try(:anchor_links_schema) || nil
17
+ @anchor_meta_schema = resource_klass.try(:anchor_meta_schema) || nil
18
+ end
19
+
20
+ def express(...)
21
+ raise NotImplementedError
22
+ end
23
+
24
+ private
25
+
26
+ delegate :convert_case, to: Anchor::Types
27
+
28
+ def schema_fetchable_fields(context:, include_all_fields:)
29
+ return fields unless statically_determinable_fetchable_fields? && !include_all_fields
30
+ @resource_klass.anchor_fetchable_fields(context)
31
+ end
32
+
33
+ def statically_determinable_fetchable_fields?
34
+ @resource_klass.singleton_class.method_defined?(:anchor_fetchable_fields)
35
+ end
36
+
37
+ # @return [Anchor::Types::Property]
38
+ def id_property
39
+ # TODO: resource_key_type can also return a proc
40
+ res_key_type = case resource_key_type
41
+ when :integer then Anchor::Types::Integer
42
+ else Anchor::Types::String
43
+ end
44
+
45
+ Anchor::Types::Property.new(:id, res_key_type)
46
+ end
47
+
48
+ # @return [Anchor::Types::Property]
49
+ def type_property
50
+ Anchor::Types::Property.new(:type, Anchor::Types::Literal.new(_type))
51
+ end
52
+
53
+ # @param included_fields [Array<Symbol>]
54
+ # @return [Array<Anchor::Types::Property>]
55
+ def anchor_attributes_properties(included_fields:)
56
+ _attributes.except(:id).filter_map do |attr, options|
57
+ next if included_fields.exclude?(attr.to_sym)
58
+ description = @anchor_attributes_descriptions[attr]
59
+ next Anchor::Types::Property.new(
60
+ convert_case(attr),
61
+ @anchor_attributes[attr],
62
+ false,
63
+ description,
64
+ ) if @anchor_attributes.key?(attr)
65
+
66
+ type = begin
67
+ model_method = options[:delegate] || attr
68
+ resource_method = attr
69
+
70
+ model_method_defined = _model_class.try(
71
+ :method_defined?,
72
+ model_method.to_sym,
73
+ ) && !_model_class.instance_method(model_method.to_sym)
74
+ .owner.is_a?(ActiveRecord::AttributeMethods::GeneratedAttributeMethods)
75
+ resource_method_defined = @anchor_method_added_count[resource_method.to_sym] > 1
76
+ serializer_defined = (_model_class.try(:attribute_types) || {})[model_method.to_s].respond_to?(:coder)
77
+ method_defined = model_method_defined || resource_method_defined || serializer_defined
78
+
79
+ column = !method_defined && _model_class.try(:columns_hash).try(:[], model_method.to_s)
80
+ if column
81
+ type = Anchor::Types::Inference::ActiveRecord::SQL.from(column)
82
+ description ||= column.comment if Anchor.config.use_active_record_comment
83
+ check_presence = type.is_a?(Anchor::Types::Maybe) && Anchor.config.use_active_record_validations
84
+ if check_presence && _model_class.validators_on(model_method).any? do |v|
85
+ if v.is_a?(ActiveRecord::Validations::NumericalityValidator)
86
+ opts = v.options.with_indifferent_access
87
+ !(opts[:allow_nil] || opts[:if] || opts[:unless] || opts[:on])
88
+ elsif v.is_a?(ActiveRecord::Validations::PresenceValidator)
89
+ opts = v.options.with_indifferent_access
90
+ !(opts[:if] || opts[:unless] || opts[:on])
91
+ end
92
+ end
93
+ type.type
94
+ else
95
+ type
96
+ end
97
+ else
98
+ Anchor::Types::Unknown
99
+ end
100
+ end
101
+
102
+ Anchor::Types::Property.new(convert_case(attr), type, false, description)
103
+ end
104
+ end
105
+
106
+ # @param included_fields [Array<Symbol>]
107
+ # @return [Anchor::Types::Property, NilClass]
108
+ def anchor_relationships_property(included_fields:)
109
+ anchor_relationships_properties(included_fields:).then do |properties|
110
+ break if properties.blank?
111
+ Anchor::Types::Property.new(:relationships, Anchor::Types::Object.new(properties))
112
+ end
113
+ end
114
+
115
+ def anchor_links_property
116
+ if @anchor_links_schema
117
+ Anchor::Types::Property.new("links", @anchor_links_schema, false)
118
+ end
119
+ end
120
+
121
+ def anchor_meta_property
122
+ if @anchor_meta_schema
123
+ Anchor::Types::Property.new("meta", @anchor_meta_schema, false)
124
+ end
125
+ end
126
+
127
+ # @param included_fields [Array<Symbol>]
128
+ # @return [Array<Anchor::Types::Property>]
129
+ def anchor_relationships_properties(included_fields:)
130
+ _relationships.filter_map do |name, rel|
131
+ next if included_fields.exclude?(name.to_sym)
132
+ description = @anchor_relationships_descriptions[name]
133
+ relationship_type = relationship_type_for(rel, rel.resource_klass, name) if @anchor_relationships.exclude?(name)
134
+
135
+ relationship_type ||= begin
136
+ anchor_relationship = @anchor_relationships[name]
137
+
138
+ type = if (resources = anchor_relationship.resources)
139
+ references = resources.map do |resource_klass|
140
+ Anchor::Types::Reference.new(resource_klass.anchor_schema_name)
141
+ end
142
+ null_type = Array.wrap(anchor_relationship.null_elements.presence && Anchor::Types::Null)
143
+ Anchor::Types::Union.new(references + null_type)
144
+ else
145
+ Anchor::Types::Reference.new(anchor_relationship.resource.anchor_schema_name)
146
+ end
147
+
148
+ type = Anchor::Types::Inference::JSONAPI.wrapper_from_relationship(rel).call(type)
149
+ anchor_relationship.null.present? ? Anchor::Types::Maybe.new(type) : type
150
+ end
151
+
152
+ use_optional = Anchor.config.infer_nullable_relationships_as_optional
153
+ if use_optional && relationship_type.is_a?(Anchor::Types::Maybe)
154
+ Anchor::Types::Property.new(convert_case(name), relationship_type.type, true, description)
155
+ else
156
+ Anchor::Types::Property.new(convert_case(name), relationship_type, false, description)
157
+ end
158
+ end
159
+ end
160
+
161
+ # rubocop:disable Layout/LineLength
162
+ # @param rel [Relationship]
163
+ # @param resource_klass [Anchor::Resource]
164
+ # @param name [String, Symbol]
165
+ # @return [Anchor::Types::Reference, Anchor::Types::Array<Anchor::Types::Reference>, Anchor::Types::Maybe<Anchor::Types::Reference>, Anchor::Types::Union<Anchor::Types::Reference>]
166
+ def relationship_type_for(rel, resource_klass, name)
167
+ rel_type = if rel.polymorphic? && rel.respond_to?(:polymorphic_types) # 0.11.0.beta2
168
+ resource_klasses = rel.polymorphic_types.map { |t| resource_klass_for(t) }
169
+ Anchor::Types::Union.new(resource_klasses.map { |rk| Anchor::Types::Reference.new(rk.anchor_schema_name) })
170
+ elsif rel.polymorphic? && rel.class.respond_to?(:polymorphic_types) # TODO: < 0.11.0.beta2
171
+ resource_klasses = rel.class.polymorphic_types.map { |t| resource_klass_for(t) }
172
+ Anchor::Types::Union.new(resource_klasses.map { |rk| Anchor::Types::Reference.new(rk.anchor_schema_name) })
173
+ end
174
+
175
+ rel_type ||= Anchor::Types::Reference.new(resource_klass.anchor_schema_name)
176
+ model_relationship_name = (rel.options[:relation_name] || name).to_s
177
+ reflection = _model_class.try(:reflections).try(:[], model_relationship_name)
178
+ wrapper = if reflection
179
+ Anchor::Types::Inference::ActiveRecord.wrapper_from_reflection(reflection)
180
+ else
181
+ Anchor::Types::Inference::JSONAPI.wrapper_from_relationship(rel)
182
+ end
183
+
184
+ wrapper.call(rel_type)
185
+ end
186
+ end
187
+ # rubocop:enable Layout/LineLength
188
+ end
@@ -0,0 +1,21 @@
1
+ module Anchor
2
+ class Schema
3
+ class << self
4
+ Register = Struct.new(:resources, :enums, keyword_init: true)
5
+
6
+ def register
7
+ Register.new(resources: @resources || [], enums: @enums || [])
8
+ end
9
+
10
+ def resource(resource)
11
+ @resources ||= []
12
+ @resources.push(resource)
13
+ end
14
+
15
+ def enum(enum)
16
+ @enums ||= []
17
+ @enums.push(enum)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ module Anchor
2
+ class SchemaGenerator
3
+ def initialize(register:, context:, include_all_fields:, exclude_fields:)
4
+ @register = register
5
+ @context = context
6
+ @include_all_fields = include_all_fields
7
+ @exclude_fields = exclude_fields
8
+ end
9
+
10
+ def self.call(...)
11
+ new(...).call
12
+ end
13
+
14
+ def call
15
+ raise NotImplementedError
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,122 @@
1
+ module Anchor::TypeScript
2
+ class FileStructure
3
+ # @param file_name [String] name of file, e.g. model.ts
4
+ # @param type [Anchor::Types]
5
+ Import = Struct.new(:file_name, :type, keyword_init: true)
6
+ class FileUtils
7
+ def self.imports_to_code(imports)
8
+ imports.group_by(&:file_name).map do |file_name, file_imports|
9
+ named_imports = file_imports.map do |import|
10
+ import.type.try(:anchor_schema_name) || import.type.name
11
+ end.join(",")
12
+
13
+ "import type { #{named_imports} } from \"./#{file_name[..-4]}\";"
14
+ end.join("\n") + "\n"
15
+ end
16
+
17
+ def self.def_to_code(identifier, object)
18
+ expression = Anchor::TypeScript::Serializer.type_string(object)
19
+ "type #{identifier} = #{expression};" + "\n"
20
+ end
21
+
22
+ def self.export_code(identifier)
23
+ "export { type #{identifier} };" + "\n"
24
+ end
25
+ end
26
+
27
+ def initialize(definition)
28
+ @definition = definition
29
+ @name = definition.name
30
+ @object = definition.object
31
+ end
32
+
33
+ def name
34
+ "#{@definition.name}.ts"
35
+ end
36
+
37
+ def to_code(manually_editable: false)
38
+ imports_string = FileUtils.imports_to_code(imports)
39
+ name = manually_editable ? "Model" : @name
40
+ typedef = FileUtils.def_to_code(name, @object)
41
+ export_string = FileUtils.export_code(@definition.name)
42
+
43
+ if manually_editable
44
+ start_autogen = "// START AUTOGEN\n"
45
+ end_autogen = "// END AUTOGEN\n"
46
+ unedited_export_def = "type #{@name} = Model;\n"
47
+ [start_autogen, imports_string, typedef, end_autogen, unedited_export_def, export_string].join("\n")
48
+ else
49
+ [imports_string, typedef, export_string].join("\n")
50
+ end
51
+ end
52
+
53
+ # @return [Array<Import>]
54
+ def imports
55
+ shared_imports + relationship_imports
56
+ end
57
+
58
+ private
59
+
60
+ # @return [Array<Import>]
61
+ def shared_imports
62
+ (utils_to_import + enums_to_import).map { |type| Import.new(file_name: "shared.ts", type:) }
63
+ end
64
+
65
+ # @return [Array<Import>]
66
+ def relationship_imports
67
+ relationships_to_import
68
+ .reject { |type| type.anchor_schema_name == @name }
69
+ .map { |type| Import.new(file_name: "#{type.anchor_schema_name}.ts", type:) }
70
+ end
71
+
72
+ def relationships_to_import
73
+ relationships = @object.properties.find { |p| p.name == :relationships }
74
+ return [] if relationships.nil? || relationships.type.try(:properties).nil?
75
+ relationships.type.properties.flat_map { |p| references_from_type(p.type) }.uniq.sort_by(&:anchor_schema_name)
76
+ end
77
+
78
+ def references_from_type(type)
79
+ case type
80
+ when Anchor::Types::Array, Anchor::Types::Maybe then references_from_type(type.type)
81
+ when Anchor::Types::Union then type.types.flat_map { |t| references_from_type(t) }
82
+ when Anchor::Types::Reference then [type]
83
+ end.uniq.sort_by(&:anchor_schema_name)
84
+ end
85
+
86
+ def utils_to_import
87
+ maybe_type = has_maybe?(@object).presence && Anchor::Types::Reference.new("Maybe")
88
+ [maybe_type].compact
89
+ end
90
+
91
+ def has_maybe?(type)
92
+ case type
93
+ when Anchor::Types::Maybe then true
94
+ when Anchor::Types::Array then has_maybe?(type.type)
95
+ when Anchor::Types::Union then type.types.any? { |t| has_maybe?(t) }
96
+ when Anchor::Types::Object, Anchor::Types::Object.singleton_class then type.properties.any? do |p|
97
+ has_maybe?(p)
98
+ end
99
+ when Anchor::Types::Property then has_maybe?(type.type)
100
+ else false
101
+ end
102
+ end
103
+
104
+ def enums_to_import
105
+ enums_to_import_from_type(@object).uniq.sort_by(&:anchor_schema_name)
106
+ end
107
+
108
+ def enums_to_import_from_type(type)
109
+ case type
110
+ when Anchor::Types::Enum.singleton_class then [type]
111
+ when Anchor::Types::Array then enums_to_import_from_type(type.type)
112
+ when Anchor::Types::Maybe then enums_to_import_from_type(type.type)
113
+ when Anchor::Types::Union then type.types.flat_map { |t| enums_to_import_from_type(t) }
114
+ when Anchor::Types::Object, Anchor::Types::Object.singleton_class then type.properties.flat_map do |p|
115
+ enums_to_import_from_type(p)
116
+ end
117
+ when Anchor::Types::Property then enums_to_import_from_type(type.type)
118
+ else []
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,33 @@
1
+ module Anchor::TypeScript
2
+ class Resource < Anchor::Resource
3
+ Definition = Struct.new(:name, :object, keyword_init: true)
4
+
5
+ def express(...)
6
+ @object = object(...)
7
+ expression = Anchor::TypeScript::Serializer.type_string(@object)
8
+ "export type #{anchor_schema_name} = " + expression + ";"
9
+ end
10
+
11
+ def definition(...)
12
+ @object = object(...)
13
+ Definition.new(name: anchor_schema_name, object: @object)
14
+ end
15
+
16
+ def object(context: {}, include_all_fields:, exclude_fields:)
17
+ included_fields = schema_fetchable_fields(context:, include_all_fields:)
18
+ included_fields -= exclude_fields if exclude_fields
19
+
20
+ relationships_property = anchor_relationships_property(included_fields:)
21
+ if relationships_property.nil? && Anchor.config.empty_relationship_type
22
+ relationships_property = Anchor::Types::Property.new(:relationships, Anchor.config.empty_relationship_type.call)
23
+ end
24
+
25
+ properties = [id_property, type_property] +
26
+ Array.wrap(anchor_attributes_properties(included_fields:)) +
27
+ Array.wrap(relationships_property) +
28
+ [anchor_meta_property].compact + [anchor_links_property].compact
29
+
30
+ Anchor::Types::Object.new(properties)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,82 @@
1
+ module Anchor::TypeScript
2
+ class SchemaGenerator < Anchor::SchemaGenerator
3
+ def initialize(register:, context: {}, include_all_fields: false, exclude_fields: nil) # rubocop:disable Lint/MissingSuper
4
+ @register = register
5
+ @context = context
6
+ @include_all_fields = include_all_fields
7
+ @exclude_fields = exclude_fields
8
+ end
9
+
10
+ def call
11
+ maybe_type = "type Maybe<T> = T | null;"
12
+
13
+ enum_expressions = enums.map(&:express)
14
+ type_expressions = resources.map do |r|
15
+ r.express(
16
+ context: @context,
17
+ include_all_fields: @include_all_fields,
18
+ exclude_fields: @exclude_fields.nil? ? [] : @exclude_fields[r.anchor_schema_name.to_sym],
19
+ )
20
+ end
21
+
22
+ ([maybe_type] + enum_expressions + type_expressions).join("\n\n") + "\n"
23
+ end
24
+
25
+ private
26
+
27
+ def resources
28
+ @resources ||= @register.resources.map { |r| Anchor::TypeScript::Resource.new(r) }
29
+ end
30
+
31
+ def enums
32
+ @enums ||= @register.enums.map { |e| Anchor::TypeScript::Types::Enum.new(e) }
33
+ end
34
+ end
35
+
36
+ class MultifileSchemaGenerator < Anchor::SchemaGenerator
37
+ def initialize(register:, context: {}, include_all_fields: false, exclude_fields: nil, manually_editable: true) # rubocop:disable Lint/MissingSuper
38
+ @register = register
39
+ @context = context
40
+ @include_all_fields = include_all_fields
41
+ @exclude_fields = exclude_fields
42
+ @manually_editable = manually_editable
43
+ end
44
+
45
+ def call
46
+ [shared_file] + resource_files
47
+ end
48
+
49
+ private
50
+
51
+ def shared_file
52
+ maybe_type = "export type Maybe<T> = T | null;"
53
+
54
+ enum_expressions = enums.map(&:express)
55
+ content = ([maybe_type] + enum_expressions).join("\n\n") + "\n"
56
+ { name: "shared.ts", content: }
57
+ end
58
+
59
+ def resource_files
60
+ resources.map do |r|
61
+ definition = r.definition(
62
+ context: @context,
63
+ include_all_fields: @include_all_fields,
64
+ exclude_fields: @exclude_fields.nil? ? [] : @exclude_fields[r.anchor_schema_name.to_sym],
65
+ )
66
+
67
+ file_structure = ::Anchor::TypeScript::FileStructure.new(definition)
68
+ content = file_structure.to_code(manually_editable: @manually_editable)
69
+ name = file_structure.name
70
+ { name:, content: }
71
+ end
72
+ end
73
+
74
+ def resources
75
+ @resources ||= @register.resources.map { |r| Anchor::TypeScript::Resource.new(r) }
76
+ end
77
+
78
+ def enums
79
+ @enums ||= @register.enums.map { |e| Anchor::TypeScript::Types::Enum.new(e) }
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,60 @@
1
+ module Anchor::TypeScript
2
+ class Serializer
3
+ class << self
4
+ # rubocop:disable Layout/LineLength
5
+
6
+ def type_string(type, depth = 1)
7
+ case type
8
+ when Anchor::Types::String.singleton_class then "string"
9
+ when Anchor::Types::BigDecimal.singleton_class then "string"
10
+ when Anchor::Types::Float.singleton_class then "number"
11
+ when Anchor::Types::Integer.singleton_class then "number"
12
+ when Anchor::Types::Boolean.singleton_class then "boolean"
13
+ when Anchor::Types::Null.singleton_class then "null"
14
+ when Anchor::Types::Record, Anchor::Types::Record.singleton_class then "Record<string, #{type_string(type.try(:value_type) || Anchor::Types::Unknown)}>"
15
+ when Anchor::Types::Union then type.types.map { |type| type_string(type, depth) }.join(" | ")
16
+ when Anchor::Types::Maybe then "Maybe<#{type_string(type.type, depth)}>"
17
+ when Anchor::Types::Array then "Array<#{type_string(type.type, depth)}>"
18
+ when Anchor::Types::Literal then serialize_literal(type.value)
19
+ when Anchor::Types::Reference then type.name
20
+ when Anchor::Types::Object, Anchor::Types::Object.singleton_class then serialize_object(type, depth)
21
+ when Anchor::Types::Enum.singleton_class then type.anchor_schema_name
22
+ when Anchor::Types::Unknown.singleton_class then "unknown"
23
+ else raise RuntimeError
24
+ end
25
+ end
26
+ # rubocop:enable Layout/LineLength
27
+
28
+ private
29
+
30
+ def serialize_literal(value)
31
+ case value
32
+ when ::String, ::Symbol then "\"#{value}\""
33
+ else value.to_s
34
+ end
35
+ end
36
+
37
+ def serialize_object(type, depth)
38
+ return "{}" if type.properties.empty?
39
+ properties = type.properties.flat_map do |p|
40
+ [
41
+ p.description && "/** #{p.description} */",
42
+ "#{safe_name(p)}: #{type_string(p.type, depth + 1)};",
43
+ ].compact
44
+ end
45
+ indent = " " * (depth * 2)
46
+ properties = properties.map { |p| p.prepend(indent) }.join("\n")
47
+ ["{", properties, "}".prepend(indent[2..])].join("\n")
48
+ end
49
+
50
+ def safe_name(property)
51
+ name = property.name
52
+ if name.match?(/[^a-zA-Z0-9_]/)
53
+ "\"#{name}\""
54
+ else
55
+ name.to_s + (property.optional ? "?" : "")
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ module Anchor::TypeScript
2
+ module Types
3
+ class Enum
4
+ delegate_missing_to :@enum_klass
5
+
6
+ def initialize(enum_klass)
7
+ @enum_klass = enum_klass
8
+ end
9
+
10
+ # @return [String]
11
+ def express
12
+ ["export enum #{anchor_schema_name} {", named_constants, "}"].join("\n")
13
+ end
14
+
15
+ private
16
+
17
+ def named_constants
18
+ values.map do |name, value|
19
+ " #{name.to_s.camelize} = #{Anchor::TypeScript::Serializer.type_string(value)},"
20
+ end.join("\n")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,69 @@
1
+ module Anchor::Types::Inference
2
+ module ActiveRecord
3
+ class << self
4
+ # rubocop:disable Layout/LineLength
5
+
6
+ # @return [Proc{Type => Type, Anchor::Types::Maybe<Type>, Anchor::Types::Array<Type>}]
7
+ def wrapper_from_reflection(reflection)
8
+ case reflection
9
+ when ::ActiveRecord::Reflection::BelongsToReflection then ->(type) { belongs_to_type(reflection, type) }
10
+ when ::ActiveRecord::Reflection::HasOneReflection then ->(type) { Anchor::Types::Maybe.new(type) }
11
+ when ::ActiveRecord::Reflection::HasManyReflection then ->(type) { Anchor::Types::Array.new(type) }
12
+ when ::ActiveRecord::Reflection::HasAndBelongsToManyReflection then ->(type) { Anchor::Types::Array.new(type) }
13
+ when ::ActiveRecord::Reflection::ThroughReflection then wrapper_from_reflection(reflection.send(:delegate_reflection))
14
+ else raise "#{reflection.class.name} not supported"
15
+ end
16
+ end
17
+ # rubocop:enable Layout/LineLength
18
+
19
+ private
20
+
21
+ # @param reflection [::ActiveRecord::Reflection::BelongsToReflection]
22
+ # @param type [Anchor::Types]
23
+ # @return [Anchor::Types::Maybe<Type>, Type]
24
+ def belongs_to_type(reflection, type)
25
+ reflection.options[:optional] ? Anchor::Types::Maybe.new(type) : type
26
+ end
27
+ end
28
+
29
+ module SQL
30
+ class << self
31
+ def from(column, check_config: true)
32
+ return Anchor.config.ar_column_to_type.call(column) if check_config && Anchor.config.ar_column_to_type
33
+ type = from_sql_type(column.type)
34
+
35
+ if ["character varying[]", "text[]"].include?(column.sql_type_metadata.sql_type)
36
+ type = Anchor::Types::Array.new(Anchor::Types::String)
37
+ end
38
+
39
+ column.null ? Anchor::Types::Maybe.new(type) : type
40
+ end
41
+
42
+ def default_ar_column_to_type(column)
43
+ from(column, check_config: false)
44
+ end
45
+
46
+ private
47
+
48
+ # inspiration from https://github.com/ElMassimo/types_from_serializers/blob/146ba40bc1a0da37473cd3b705a8ca982c2d173f/types_from_serializers/lib/types_from_serializers/generator.rb#L382
49
+ def from_sql_type(type)
50
+ case type
51
+ when :boolean then Anchor::Types::Boolean
52
+ when :date then Anchor::Types::String
53
+ when :datetime then Anchor::Types::String
54
+ when :decimal then Anchor::Types::BigDecimal
55
+ when :float then Anchor::Types::Float
56
+ when :integer then Anchor::Types::Integer
57
+ when :json then Anchor::Types::Record
58
+ when :jsonb then Anchor::Types::Record
59
+ when :string then Anchor::Types::String
60
+ when :text then Anchor::Types::String
61
+ when :time then Anchor::Types::String
62
+ when :uuid then Anchor::Types::String
63
+ else Anchor::Types::Unknown
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,14 @@
1
+ module Anchor::Types::Inference
2
+ module JSONAPI
3
+ class << self
4
+ # @return [Proc{Type => Type, Anchor::Types::Array<Type>}]
5
+ def wrapper_from_relationship(relationship)
6
+ case relationship
7
+ when ::JSONAPI::Relationship::ToOne then ->(type) { type }
8
+ when ::JSONAPI::Relationship::ToMany then ->(type) { Anchor::Types::Array.new(type) }
9
+ else raise "#{relationship.class.name} not supported"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,85 @@
1
+ module Anchor
2
+ module Types
3
+ class String; end
4
+ class Float; end
5
+ class Integer; end
6
+ class BigDecimal; end
7
+ class Boolean; end
8
+ class Null; end
9
+ class Unknown; end
10
+ Record = Struct.new(:value_type)
11
+ Maybe = Struct.new(:type)
12
+ Array = Struct.new(:type)
13
+ Literal = Struct.new(:value)
14
+ Union = Struct.new(:types)
15
+ Reference = Struct.new(:name) do
16
+ def anchor_schema_name
17
+ name
18
+ end
19
+ end
20
+ Property = Struct.new(:name, :type, :optional, :description)
21
+ class Object
22
+ attr_reader :properties
23
+
24
+ def initialize(properties)
25
+ @properties = properties || []
26
+ end
27
+
28
+ class << self
29
+ def properties
30
+ @properties ||= []
31
+ end
32
+
33
+ def property(name, type, optional: nil, description: nil)
34
+ @properties ||= []
35
+ @properties.push(Property.new(name, type, optional, description))
36
+ end
37
+ end
38
+ end
39
+
40
+ Relationship = Struct.new(:resource, :resources, :null, :null_elements, keyword_init: true)
41
+
42
+ class Enum
43
+ class << self
44
+ attr_reader :values
45
+
46
+ def anchor_schema_name(name = nil)
47
+ @anchor_schema_name ||= name || default_name
48
+ end
49
+
50
+ def value(name, value)
51
+ @values ||= []
52
+ @values.push([name, Types::Literal.new(value)])
53
+ end
54
+
55
+ private
56
+
57
+ def default_name
58
+ s_name = name.split("::").last
59
+ s_name.end_with?("Enum") ? s_name.sub(/Enum\Z/, "") : s_name
60
+ end
61
+ end
62
+ end
63
+
64
+ def self.camelize_without_inflection(val)
65
+ vals = val.split("_")
66
+ if vals.length == 1
67
+ vals[0]
68
+ else
69
+ ([vals[0]] + vals[1..].map(&:capitalize)).join("")
70
+ end
71
+ end
72
+
73
+ # @param value [String, Symbol]
74
+ # @return [String]
75
+ def self.convert_case(value)
76
+ case Anchor.config.field_case
77
+ when :camel then value.to_s.underscore.camelize(:lower)
78
+ when :camel_without_inflection then camelize_without_inflection(value.to_s.underscore)
79
+ when :kebab then value.to_s.underscore.dasherize
80
+ when :snake then value.to_s.underscore
81
+ else value
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,3 @@
1
+ module Anchor
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,22 @@
1
+ require "anchor/types"
2
+ require "anchor/anchor"
3
+ require "anchor/config"
4
+ require "anchor/resource"
5
+ require "anchor/schema_generator"
6
+ require "anchor/concerns/annotatable"
7
+ require "anchor/concerns/custom_linkable"
8
+ require "anchor/concerns/custom_meta"
9
+ require "anchor/concerns/static_context"
10
+ require "anchor/concerns/type_inferable"
11
+ require "anchor/concerns/schema_serializable"
12
+ require "anchor/schema"
13
+ require "anchor/types/inference/jsonapi"
14
+ require "anchor/types/inference/active_record"
15
+ require "anchor/type_script/file_structure"
16
+ require "anchor/type_script/types"
17
+ require "anchor/type_script/schema_generator"
18
+ require "anchor/type_script/serializer"
19
+ require "anchor/type_script/resource"
20
+ require "anchor/json_schema/serializer"
21
+ require "anchor/json_schema/schema_generator"
22
+ require "anchor/json_schema/resource"
metadata ADDED
@@ -0,0 +1,136 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jsonapi-resources-anchor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matt
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 1980-01-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jsonapi-resources
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec-rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '7.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '7.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: yard
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: yard-activesupport-concern
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description:
84
+ email:
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - lib/anchor/anchor.rb
90
+ - lib/anchor/concerns/annotatable.rb
91
+ - lib/anchor/concerns/custom_linkable.rb
92
+ - lib/anchor/concerns/custom_meta.rb
93
+ - lib/anchor/concerns/schema_serializable.rb
94
+ - lib/anchor/concerns/static_context.rb
95
+ - lib/anchor/concerns/type_inferable.rb
96
+ - lib/anchor/config.rb
97
+ - lib/anchor/json_schema/resource.rb
98
+ - lib/anchor/json_schema/schema_generator.rb
99
+ - lib/anchor/json_schema/serializer.rb
100
+ - lib/anchor/resource.rb
101
+ - lib/anchor/schema.rb
102
+ - lib/anchor/schema_generator.rb
103
+ - lib/anchor/type_script/file_structure.rb
104
+ - lib/anchor/type_script/resource.rb
105
+ - lib/anchor/type_script/schema_generator.rb
106
+ - lib/anchor/type_script/serializer.rb
107
+ - lib/anchor/type_script/types.rb
108
+ - lib/anchor/types.rb
109
+ - lib/anchor/types/inference/active_record.rb
110
+ - lib/anchor/types/inference/jsonapi.rb
111
+ - lib/anchor/version.rb
112
+ - lib/jsonapi-resources-anchor.rb
113
+ homepage:
114
+ licenses:
115
+ - MIT
116
+ metadata: {}
117
+ post_install_message:
118
+ rdoc_options: []
119
+ require_paths:
120
+ - lib
121
+ required_ruby_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '3.1'
126
+ required_rubygems_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0'
131
+ requirements: []
132
+ rubygems_version: 3.5.11
133
+ signing_key:
134
+ specification_version: 4
135
+ summary: jsonapi-resources type annotation, inference, and schema/docs generation
136
+ test_files: []