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_typeallows custom mappings, see spec/example/config/initializers/anchor.rbAnchor.config.use_active_record_presencecan be set totrueto infer nullable attributes (i.e. fields that do not specifynull: falsein schema.rb) as non-null when an unconditionalvalidates :attribute_name, presence: trueis 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_optionaltotrueto 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
nameargument, specify any type from the table in Anchor::Types
For .relationship:
- after the
nameargument, 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 endSee 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 endclass Schema < Anchor::Schema resource CommentResource # register resources resource UserResource resource PostResource enum UserRoleEnum # register enums endSchema.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) endclass CustomPayload < Anchor::Types::Object property :id, Anchor::Types::String, optional: true, description: "ID of payload." endclass 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 } endVery 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" endJSONAPI::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 endSchema.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", }