Skip to content

Commit e9eb26c

Browse files
committed
chore: misc updates
1 parent ecef0ee commit e9eb26c

File tree

8 files changed

+60
-280
lines changed

8 files changed

+60
-280
lines changed

README.md

Lines changed: 20 additions & 253 deletions
Original file line numberDiff line numberDiff line change
@@ -1,264 +1,30 @@
11
# JSON:API Resource Schema Generation: Anchor
22

3+
[Documentation](https://anchor-gem.vercel.app/docs)
4+
35
Easily generate TypeScript schemas, JSON Schemas, or any schema of your choice
46
from [cerebris/jsonapi-resources](https://github.com/cerebris/jsonapi-resources)
57
`JSONAPI::Resource` classes.
68

79
Ideally, API schemas have the types of each payload fully specified.
810

9-
To conveniently reach that ideal in a Ruby codebase that doesn't have static
10-
type signatures, `Anchor` automates type inference for attributes and
11-
relationships via the underlying ActiveRecord model of the `JSONAPI::Resource`.
12-
13-
If a type for an attribute or relationship can't be inferred or you'd like to
14-
specify it statically, you can annotate the `attribute` or `relationship` via
15-
types defined in `Anchor::Types`, see [Annotations](#annotations).
16-
17-
This gem provides TypeScript and JSON Schema generators with
18-
`Anchor::TypeScript::SchemaGenerator` and `Anchor::JSONSchema::SchemaGenerator`.
19-
20-
See the [example](./spec/example) Rails app for a fully functional example using
21-
`Anchor`. See
22-
[example_schema_snapshot_spec.rb](./spec/anchor/example_schema_snapshot_spec.rb)
23-
for `Schema` generation examples.
24-
25-
## Inference
26-
27-
### Attributes
28-
29-
`JSONAPI::Resource` attributes are inferred via introspection of the resource's
30-
underlying ActiveRecord model (`JSONAPI::Resources._model_class`).
31-
32-
`ActiveRecord::Base.columns_hash[attribute]` is used to get the SQL type and is
33-
then mapped to an `Anchor::Type` in
34-
`Anchor::Types::Inference::ActiveRecord::SQL.from`.
35-
36-
- `Anchor.config.ar_column_to_type` allows custom mappings, see
37-
[spec/example/config/initializers/anchor.rb](./spec/example/config/initializers/anchor.rb)
38-
- `Anchor.config.use_active_record_presence` can be set to `true` to infer
39-
nullable attributes (i.e. fields that do not specify `null: false` in
40-
schema.rb) as non-null when an unconditional
41-
`validates :attribute_name, presence: true` is present on the model
42-
43-
### Relationships
44-
45-
`JSONAPI::Resource` relationships refer to other `JSONAPI::Resource` classes, so
46-
the `JSONAPI::Resource.anchor_schema_name` of the related relationship is used
47-
as a reference in the TypeScript and JSON Schema adapters.
48-
49-
`Anchor` infers whether the associated resource is nullable or an array via
50-
`JSONAPI::Resource._model_class.reflections[name]` where `name` is the first
51-
element of the `JSONAPI::Resource._relationships` `[name, relationship]` tuples.
52-
53-
| ActiveRecord Association | Inferred `Anchor::Type` |
54-
| -------------------------------------- | ----------------------- |
55-
| `belongs_to :relation` | `Relation` |
56-
| `belongs_to :relation, optional: true` | `Maybe<Relation>` |
57-
| `has_one :relation` | `Maybe<Relation>` |
58-
| `has_many :relations` | `Array<Relation>` |
59-
| `has_and_belogs_to_many :relations` | `Array<Relation>` |
60-
61-
- set `Anchor.config.infer_nullable_relationships_as_optional` to `true` to
62-
infer that the property associated with a nullable relationship will not be
63-
present if it's null
64-
- e.g. in TypeScript, setting the config to true will infer
65-
`{ relation?: Relation }` over `{ relation: Maybe<Relation> }`
66-
67-
## Annotations
68-
69-
The APIs of `JSONAPI::Resource.attribute` and `JSONAPI::Resource.relationship`
70-
have been modified to take in an optional type parameter.
71-
72-
If the type can be inferred from the underlying ActiveRecord model the type
73-
argument isn't required.
74-
75-
If there is no type argument and the type cannot be inferred, then the type of
76-
the property will default to `unknown`.
77-
78-
The type argument has precedence over the inferred type.
79-
80-
For `.attribute`:
81-
82-
- after the `name` argument, specify any type from the table in
83-
[Anchor::Types](#anchortypes)
84-
85-
For `.relationship`:
86-
87-
- after the `name` argument, specify a `Anchor::Types::Relationship`
88-
89-
The APIs of `JSONAPI::Resource.attribute` and `JSONAPI::Resource.relationship`
90-
remain the same if a type argument is not given.
91-
92-
If a type argument is given, the `options` for each will be the third argument.
93-
94-
### Descriptions
95-
96-
If the `description` key is present in the options to `.attribute` or
97-
`.relationship`, it will be used in the provided TypeScript Schema generator as
98-
a comment for the property. The comment should show up in the LSP hover info.
99-
100-
With `Anchor.config.use_active_record_presence = true`, a default `description`
101-
will be inferred from the ActiveRecord column comment if it exists. Examples of
102-
both in
103-
[spec/example/app/resources/exhaustive_resource.rb](./spec/example/app/resources/exhaustive_resource.rb)
104-
and its resulting [TypeScript schema](./spec/example/test/files/schema.ts).
105-
106-
## Generators
107-
108-
This gem provides generators for JSON Schema and TypeScript schemas via
109-
`Schema.generate(adapter: :type_script | :json_schema)`.
110-
111-
### Custom Generator
112-
113-
You can create your own generator by providing it to
114-
`Schema.generate(adapter: MyGenerator)`.
115-
116-
It should inherit from `Anchor::SchemaGenerator`, e.g.
117-
118-
```rb
119-
class MyGenerator < Anchor::SchemaGenerator
120-
def call
121-
raise NotImplementedError
122-
end
123-
end
124-
```
125-
126-
See `Anchor::TypeScript::Resource`, `Anchor::TypeScript::Serializer`, and
127-
`Anchor::TypeScript::SchemaGenerator` and the equivalents under
128-
`Anchor::JSONSchema` for examples.
129-
130-
## Configuration
131-
132-
| Name | Type | Description |
133-
| ------------------------------------------ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
134-
| `field_case` | `:camel \| :snake \| :kebab` | Case format for `attributes` and `relationships` properties. |
135-
| `ar_column_to_type` | `Proc` | `ActiveRecord::Base.columns_hash[attribute]` to `Anchor::Type` |
136-
| `use_active_record_comment` | `Boolean` | whether to use ActiveRecord comments as default value of `description` option |
137-
| `use_active_record_presence` | `Boolean` | check presence of unconditional `validates :attribute, presence: true` to infer database nullable attribute as non-null |
138-
| `infer_nullable_relationships_as_optional` | `Boolean` | `true` infers nullable relationships as optional. e.g. in TypeScript, `true` infers `{ relation?: Relation }` over `{ relation: Maybe<Relation> }` |
139-
140-
## Guides
141-
142-
### Create a schema serializable resource
143-
144-
```rb
145-
class ApplicationResource
146-
include Anchor::SchemaSerializable
147-
end
148-
149-
class SomeScope::UserResource < ApplicaionResource
150-
# optional anchor_schema_name definition
151-
# defaults to part of the string after the last :: (or class name itself if not nested) and removes Resource, in this case User
152-
anchor_schema_name "SpecialUser"
153-
154-
attribute :name
155-
attribute :role, Anchor::Types::String
156-
157-
relationship :profile, to: :one
158-
relationship :group, Anchor::Types::Relationship.new(resource: GroupResource, null: true), to: :one
159-
end
160-
```
161-
162-
### `Anchor::Schema`
163-
164-
```rb
165-
class Schema < Anchor::Schema
166-
resource CommentResource # register resources
167-
resource UserResource
168-
resource PostResource
169-
170-
enum UserRoleEnum # register enums
171-
end
172-
```
173-
174-
`Schema.generate` will return the schema in a `String`.
175-
176-
Note: Currently, dependent resources and enums do not have their types
177-
generated. _All_ resources and enums must be registered as part of the schema.
178-
179-
### `Anchor::Types`
180-
181-
| `Anchor (type = Types::...)` | TypeScript type expression |
182-
| ---------------------------- | ------------------------------------------------------------------------- |
183-
| `Types::String` | `string` |
184-
| `Types::Integer` | `number` |
185-
| `Types::Float` | `number` |
186-
| `Types::BigDecimal` | `string` |
187-
| `Types::Boolean` | `boolean` |
188-
| `Types::Null` | `null` |
189-
| `Types::Unknown` | `unknown` |
190-
| `Types::Maybe.new(T)` | `Maybe<T>` |
191-
| `Types::Array.new(T)` | `Array<T>` |
192-
| `Types::Record` | `Record<string, unknown>` |
193-
| `Types::Record.new(T)` | `Record<string, T>` |
194-
| `Types::Reference.new(name)` | `name` (directly used as type identifier) |
195-
| `Types::Literal.new(value)` | `"#{value}"` if `string`, else `value.to_s` |
196-
| `Types::Enum` | `Enum.anchor_schema_name` (directly used as type identifier) |
197-
| `Types::Union.new(Ts)` | `Ts[0] \| Ts[1] \| ...` |
198-
| `Types::Object.new(props)` | `{ [props[0].name]: props[0].type, [props[1].name]: props[1].type, ... }` |
199-
200-
Note: The TypeScript type expression is derived from the
201-
`Anchor::TypeScript::Serializer` this gem provides for TypeScript schema
202-
generation. See `Anchor::JSONSchema::Serializer` for the given JSON Schema
203-
generator.
204-
205-
```rb
206-
module Anchor::Types
207-
# @!attribute [r] resource
208-
# @return [JSONAPI::Resource, NilClass] the associated resource
209-
# @!attribute [r] resources
210-
# @return [Array<JSONAPI::Resource>, NilClass] union of associated resources
211-
# @!attribute [r] null
212-
# @return [Boolean] whether the relationship can be `null`
213-
# @!attribute [r] null_elements
214-
# @return [Boolean] whether the elements in a _many_ relationship can be `null`
215-
Relationship = Struct.new(:resource, :resources, :null, :null_elements, keyword_init: true)
216-
end
217-
```
218-
219-
#### `Anchor::Types::Object`
220-
221-
```rb
222-
class CustomPayload < Anchor::Types::Object
223-
property :id, Anchor::Types::String, optional: true, description: "ID of payload."
224-
end
225-
```
226-
227-
#### `Anchor::Types::Enum`
228-
229-
```rb
230-
class UserRoleEnum < Anchor::Types::Enum
231-
anchor_schema_name "UserRole" # optional, similar logic to Resource but removes Enum
232-
233-
# First argument is the enum member identifier that gets camelized
234-
# Second argument is the value
235-
value :admin, "admin"
236-
value :content_creator, "content_creator"
237-
value :external, "external"
238-
value :guest, "guest"
239-
value :system, "system"
240-
end
11+
`jsonapi-resources-anchor` provides:
24112

242-
# alternatively
243-
class User < ApplicationRecord
244-
enum :role, {
245-
admin: "admin",
246-
conent_creator: "content_creator",
247-
external: "external",
248-
guest: "guest",
249-
system: "system",
250-
}
251-
end
252-
253-
class UserRoleEnum < Anchor::Types::Enum
254-
User.roles.each { |key, val| value key, val }
255-
end
256-
```
13+
- [Type inference](https://anchor-gem.vercel.app/docs/Features/type_inference)
14+
via the underlying ActiveRecord model of a resource
15+
- [Type annotation](https://anchor-gem.vercel.app/docs/Features/type_annotation),
16+
e.g. `attribute :name_length, Anchor::Types::Integer`
17+
- [Configuration](https://anchor-gem.vercel.app/docs/API/configuration), e.g.
18+
setting the case (camel, snake, etc.) of properties and deriving TypeScript
19+
comments from database comments
20+
- TypeScript and JSON Schema generators via
21+
`Anchor::TypeScript::SchemaGenerator` and
22+
`Anchor::JSONSchema::SchemaGenerator`
25723

258-
Very similar to
259-
[rmosolgo/graphql-ruby](https://github.com/rmosolgo/graphql-ruby) enums.
24+
See the [example](./spec/example) Rails app for a fully functional app using
25+
`Anchor`.
26026

261-
## Example
27+
## TypeScript Demo
26228

26329
Given:
26430

@@ -299,7 +65,8 @@ ActiveRecord Schema:
29965
`JSONAPI::Resource` classes:
30066

30167
```rb
302-
class ApplicaionResource
68+
class ApplicationResource < JSONAPI::Resource
69+
abstract
30370
include Anchor::SchemaSerializable
30471
end
30572

@@ -348,8 +115,8 @@ class Schema < Anchor::Schema
348115
end
349116
```
350117

351-
`Schema.generate(adapter: :type_script)` will return the schema below in a
352-
`String`:
118+
`Anchor::TypeScript::SchemaGenerator.call(register: Schema.register)` will
119+
return the schema below in a `String`:
353120

354121
```ts
355122
type Maybe<T> = T | null;

lib/anchor/config.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class Config
1111
def initialize
1212
@ar_column_to_type = nil
1313
@field_case = nil
14-
@use_active_record_presence = nil
14+
@use_active_record_presence = true
1515
@use_active_record_comment = nil
1616
@infer_nullable_relationships_as_optional = nil
1717
@empty_relationship_type = nil

lib/anchor/json_schema/schema_generator.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ module Anchor::JSONSchema
22
class SchemaGenerator < Anchor::SchemaGenerator
33
delegate :type_property, to: Anchor::JSONSchema::Serializer
44

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+
512
def call
613
result = {
714
"$schema" => "https://json-schema.org/draft-07/schema",

lib/anchor/resource.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def anchor_relationships_properties(included_fields:)
162162
# @param rel [Relationship]
163163
# @param resource_klass [Anchor::Resource]
164164
# @param name [String, Symbol]
165-
# @return [Anchor::Types::Reference, Anchor::Types::Array<Anchor::Types::Reference>, Anchor::Types::Maybe<Anchor::Types::Reference>]
165+
# @return [Anchor::Types::Reference, Anchor::Types::Array<Anchor::Types::Reference>, Anchor::Types::Maybe<Anchor::Types::Reference>, Anchor::Types::Union<Anchor::Types::Reference>]
166166
def relationship_type_for(rel, resource_klass, name)
167167
rel_type = if rel.polymorphic? && rel.respond_to?(:polymorphic_types) # 0.11.0.beta2
168168
resource_klasses = rel.polymorphic_types.map { |t| resource_klass_for(t) }

lib/anchor/schema.rb

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,6 @@ def enum(enum)
1616
@enums ||= []
1717
@enums.push(enum)
1818
end
19-
20-
def generate(context: {}, adapter: :type_script, include_all_fields: false, exclude_fields: nil)
21-
adapter = case adapter
22-
when :type_script then Anchor::TypeScript::SchemaGenerator
23-
when :json_schema then Anchor::JSONSchema::SchemaGenerator
24-
else adapter
25-
end
26-
27-
adapter.call(
28-
register:,
29-
context:,
30-
include_all_fields:,
31-
exclude_fields:,
32-
)
33-
end
3419
end
3520
end
3621
end

lib/anchor/type_script/schema_generator.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
module Anchor::TypeScript
22
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+
310
def call
411
maybe_type = "type Maybe<T> = T | null;"
512

spec/anchor/example_schema_snapshot_spec.rb

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,18 @@ def self.snapshot_test(filename, generate)
1818
end
1919
end
2020

21-
snapshot_test "schema.ts", -> { Schema.generate(include_all_fields: true) }
22-
snapshot_test "test_schema.ts", -> { Schema.generate(context: { role: "test" }) }
23-
snapshot_test "all_fields_false_schema.ts", -> { Schema.generate }
24-
snapshot_test "excluded_fields_schema.ts", -> { Schema.generate(exclude_fields: { User: [:name, :posts] }) }
25-
snapshot_test "json_schema.json", -> { Schema.generate(adapter: :json_schema, include_all_fields: true) }
21+
snapshot_test "schema.ts", -> {
22+
Anchor::TypeScript::SchemaGenerator.call(register: Schema.register, include_all_fields: true)
23+
}
24+
snapshot_test "test_schema.ts", -> {
25+
Anchor::TypeScript::SchemaGenerator.call(register: Schema.register, context: { role: "test" })
26+
}
27+
snapshot_test "all_fields_false_schema.ts", -> { Anchor::TypeScript::SchemaGenerator.call(register: Schema.register) }
28+
snapshot_test "excluded_fields_schema.ts", -> {
29+
Anchor::TypeScript::SchemaGenerator.call(register: Schema.register, exclude_fields: { User: [:name, :posts] })
30+
}
31+
snapshot_test "json_schema.json", -> {
32+
Anchor::JSONSchema::SchemaGenerator.call(register: Schema.register, include_all_fields: true)
33+
}
2634
end
2735
# rubocop:enable RSpec/EmptyExampleGroup

0 commit comments

Comments
 (0)