|
1 | 1 | # JSON:API Resource Schema Generation: Anchor |
2 | 2 |
|
| 3 | +[Documentation](https://anchor-gem.vercel.app/docs) |
| 4 | + |
3 | 5 | Easily generate TypeScript schemas, JSON Schemas, or any schema of your choice |
4 | 6 | from [cerebris/jsonapi-resources](https://github.com/cerebris/jsonapi-resources) |
5 | 7 | `JSONAPI::Resource` classes. |
6 | 8 |
|
7 | 9 | Ideally, API schemas have the types of each payload fully specified. |
8 | 10 |
|
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: |
241 | 12 |
|
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` |
257 | 23 |
|
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`. |
260 | 26 |
|
261 | | -## Example |
| 27 | +## TypeScript Demo |
262 | 28 |
|
263 | 29 | Given: |
264 | 30 |
|
@@ -299,7 +65,8 @@ ActiveRecord Schema: |
299 | 65 | `JSONAPI::Resource` classes: |
300 | 66 |
|
301 | 67 | ```rb |
302 | | -class ApplicaionResource |
| 68 | +class ApplicationResource < JSONAPI::Resource |
| 69 | + abstract |
303 | 70 | include Anchor::SchemaSerializable |
304 | 71 | end |
305 | 72 |
|
@@ -348,8 +115,8 @@ class Schema < Anchor::Schema |
348 | 115 | end |
349 | 116 | ``` |
350 | 117 |
|
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`: |
353 | 120 |
|
354 | 121 | ```ts |
355 | 122 | type Maybe<T> = T | null; |
|
0 commit comments