Easily generate TypeScript schemas, JSON Schemas, or any schema of your choice
from cerebris/jsonapi-resources
JSONAPI::Resource
classes.
Ideally, API schemas have the types of each payload fully specified.
To conveniently reach that ideal in a Ruby codebase that doesn't have static
type signatures, Anchor
automates type inference for attributes and
relationships via the underlying ActiveRecord model of the JSONAPI::Resource
.
If a type for an attribute or relationship can't be inferred or you'd like to
specify it statically, you can annotate the attribute
or relationship
via
types defined in Anchor::Types
, see Annotations.
This gem provides TypeScript and JSON Schema generators with
Anchor::TypeScript::SchemaGenerator
and Anchor::JSONSchema::SchemaGenerator
.
See the example Rails app for a fully functional example using
Anchor
. See
example_schema_snapshot_spec.rb
for Schema
generation examples.
JSONAPI::Resource
attributes are inferred via introspection of the resource's
underlying ActiveRecord model (JSONAPI::Resources._model_class
).
ActiveRecord::Base.columns_hash[attribute]
is used to get the SQL type and is
then mapped to an Anchor::Type
in
Anchor::Types::Inference::ActiveRecord::SQL.from
.
Anchor.config.ar_column_to_type
allows custom mappings, see spec/example/config/initializers/anchor.rbAnchor.config.use_active_record_presence
can be set totrue
to infer nullable attributes (i.e. fields that do not specifynull: false
in schema.rb) as non-null when an unconditionalvalidates :attribute_name, presence: true
is present on the model
JSONAPI::Resource
relationships refer to other JSONAPI::Resource
classes, so
the JSONAPI::Resource.anchor_schema_name
of the related relationship is used
as a reference in the TypeScript and JSON Schema adapters.
Anchor
infers whether the associated resource is nullable or an array via
JSONAPI::Resource._model_class.reflections[name]
where name
is the first
element of the JSONAPI::Resource._relationships
[name, relationship]
tuples.
ActiveRecord Association | Inferred Anchor::Type |
---|---|
belongs_to :relation |
Relation |
belongs_to :relation, optional: true |
Maybe<Relation> |
has_one :relation |
Maybe<Relation> |
has_many :relations |
Array<Relation> |
has_and_belogs_to_many :relations |
Array<Relation> |
- set
Anchor.config.infer_nullable_relationships_as_optional
totrue
to infer that the property associated with a nullable relationship will not be present if it's null- e.g. in TypeScript, setting the config to true will infer
{ relation?: Relation }
over{ relation: Maybe<Relation> }
- e.g. in TypeScript, setting the config to true will infer
The APIs of JSONAPI::Resource.attribute
and JSONAPI::Resource.relationship
have been modified to take in an optional type parameter.
If the type can be inferred from the underlying ActiveRecord model the type argument isn't required.
If there is no type argument and the type cannot be inferred, then the type of
the property will default to unknown
.
The type argument has precedence over the inferred type.
For .attribute
:
- after the
name
argument, specify any type from the table in Anchor::Types
For .relationship
:
- after the
name
argument, specify aAnchor::Types::Relationship
The APIs of JSONAPI::Resource.attribute
and JSONAPI::Resource.relationship
remain the same if a type argument is not given.
If a type argument is given, the options
for each will be the third argument.
If the description
key is present in the options to .attribute
or
.relationship
, it will be used in the provided TypeScript Schema generator as
a comment for the property. The comment should show up in the LSP hover info.
With Anchor.config.use_active_record_presence = true
, a default description
will be inferred from the ActiveRecord column comment if it exists. Examples of
both in
spec/example/app/resources/exhaustive_resource.rb
and its resulting TypeScript schema.
This gem provides generators for JSON Schema and TypeScript schemas via
Schema.generate(adapter: :type_script | :json_schema)
.
You can create your own generator by providing it to
Schema.generate(adapter: MyGenerator)
.
It should inherit from Anchor::SchemaGenerator
, e.g.
class MyGenerator < Anchor::SchemaGenerator
def call
raise NotImplementedError
end
end
See Anchor::TypeScript::Resource
, Anchor::TypeScript::Serializer
, and
Anchor::TypeScript::SchemaGenerator
and the equivalents under
Anchor::JSONSchema
for examples.
Name | Type | Description |
---|---|---|
field_case |
:camel | :snake | :kebab |
Case format for attributes and relationships properties. |
ar_column_to_type |
Proc |
ActiveRecord::Base.columns_hash[attribute] to Anchor::Type |
use_active_record_comment |
Boolean |
whether to use ActiveRecord comments as default value of description option |
use_active_record_presence |
Boolean |
check presence of unconditional validates :attribute, presence: true to infer database nullable attribute as non-null |
infer_nullable_relationships_as_optional |
Boolean |
true infers nullable relationships as optional. e.g. in TypeScript, true infers { relation?: Relation } over { relation: Maybe<Relation> } |
class ApplicationResource
include Anchor::SchemaSerializable
end
class SomeScope::UserResource < ApplicaionResource
# optional schema_name definition
# defaults to part of the string after the last :: (or class name itself if not nested) and removes Resource, in this case User
schema_name "SpecialUser"
attribute :name
attribute :role, Anchor::Types::String
relationship :profile, to: :one
relationship :group, Anchor::Types::Relationship.new(resource: GroupResource, null: true), to: :one
end
class Schema < Anchor::Schema
resource CommentResource # register resources
resource UserResource
resource PostResource
enum UserRoleEnum # register enums
end
Schema.generate
will return the schema in a String
.
Note: Currently, dependent resources and enums do not have their types generated. All resources and enums must be registered as part of the schema.
Anchor (type = Types::...) |
TypeScript type expression |
---|---|
Types::String |
string |
Types::Integer |
number |
Types::Float |
number |
Types::BigDecimal |
string |
Types::Boolean |
boolean |
Types::Null |
null |
Types::Unknown |
unknown |
Types::Maybe.new(T) |
Maybe<T> |
Types::Array.new(T) |
Array<T> |
Types::Record |
Record<string, unknown> |
Types::Record.new(T) |
Record<string, T> |
Types::Reference.new(name) |
name (directly used as type identifier) |
Types::Literal.new(value) |
"#{value}" if string , else value.to_s |
Types::Enum |
Enum.anchor_schema_name (directly used as type identifier) |
Types::Union.new(Ts) |
Ts[0] | Ts[1] | ... |
Types::Object.new(props) |
{ [props[0].name]: props[0].type, [props[1].name]: props[1].type, ... } |
Note: The TypeScript type expression is derived from the
Anchor::TypeScript::Serializer
this gem provides for TypeScript schema
generation. See Anchor::JSONSchema::Serializer
for the given JSON Schema
generator.
module Anchor::Types
# @!attribute [r] resource
# @return [JSONAPI::Resource, NilClass] the associated resource
# @!attribute [r] resources
# @return [Array<JSONAPI::Resource>, NilClass] union of associated resources
# @!attribute [r] null
# @return [Boolean] whether the relationship can be `null`
# @!attribute [r] null_elements
# @return [Boolean] whether the elements in a _many_ relationship can be `null`
Relationship = Struct.new(:resource, :resources, :null, :null_elements, keyword_init: true)
end
class CustomPayload < Anchor::Types::Object
property :id, Anchor::Types::String, optional: true, description: "ID of payload."
end
class UserRoleEnum < Anchor::Types::Enum
schema_name "UserRole" # optional, similar logic to Resource but removes Enum
# First argument is the enum member identifier that gets camelized
# Second argument is the value
value :admin, "admin"
value :content_creator, "content_creator"
value :external, "external"
value :guest, "guest"
value :system, "system"
end
# alternatively
class User < ApplicationRecord
enum :role, {
admin: "admin",
conent_creator: "content_creator",
external: "external",
guest: "guest",
system: "system",
}
end
class UserRoleEnum < Anchor::Types::Enum
User.roles.each { |key, val| value key, val }
end
Very similar to rmosolgo/graphql-ruby enums.
Given:
ActiveRecord Schema:
create_table "comments", force: :cascade do |t|
t.string "text", null: false
t.string "commentable_type"
t.bigint "commentable_id"
t.bigint "user_id", null: false
t.bigint "deleted_by_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["commentable_type", "commentable_id"], name: "index_comments_on_commentable"
t.index ["deleted_by_id"], name: "index_comments_on_deleted_by_id"
t.index ["user_id"], name: "index_comments_on_user_id"
end
create_table "posts", force: :cascade do |t|
t.string "description", null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_posts_on_user_id"
end
create_table "users", force: :cascade do |t|
t.string "name", null: false
t.integer "integer"
t.decimal "decimal"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "role"
end
JSONAPI::Resource
classes:
class ApplicaionResource
include Anchor::SchemaSerializable
end
class CommentResource < ApplicationResource
attribute :text
attribute :created_at
attribute :updated_at
attribute :inferred_unknown
attribute :type_given, Anchor::Types::String
relationship :user, to: :one
relationship :commentable, Anchor::Types::Relationship.new(resources: [UserResource, PostResource], null: true), polymorphic: true, to: :one
end
class UserResource < ApplicationResource
attribute :name
attribute :role, UserRoleEnum
relationship :comments, to: :many
relationship :posts, to: :many
end
class UserRoleEnum < Anchor::Types::Enum
schema_name "UserRole"
value :admin, "admin"
value :content_creator, "content_creator"
value :external, "external"
value :guest, "guest"
value :system, "system"
end
class PostResource < ApplicationResource
attribute :description
relationship :user, to: :one
relationship :comment, to: :many
end
class Schema < Anchor::Schema
resource CommentResource
resource UserResource
resource PostResource
enum UserRoleEnum
end
Schema.generate(adapter: :type_script)
will return the schema below in a
String
:
type Maybe<T> = T | null;
export type Comment = {
id: number;
type: "comments";
text: string;
created_at: string;
updated_at: string;
inferred_unknown: unknown;
type_given: string;
relationships: {
user: User;
commentable: Maybe<User | Post>;
};
};
export type User = {
id: number;
type: "users";
name: string;
role: UserRole;
relationships: {
comments: Array<Comment>;
posts: Array<Post>;
};
};
export type Post = {
id: number;
type: "posts";
description: string;
relationships: {
user: User;
comment: Array<Comment>;
};
};
export enum UserRole {
Admin = "admin",
ContentCreator = "content_creator",
External = "external",
Guest = "guest",
System = "system",
}