Skip to content

Easily generate TypeScript schemas, JSON Schemas, or any schema of your choice from cerebris/jsonapi-resources JSONAPI::Resource classes.

License

Notifications You must be signed in to change notification settings

mattkhan/jsonapi-resources-anchor

Repository files navigation

JSON:API Resource Schema Generation: Anchor

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.

Inference

Attributes

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.rb
  • Anchor.config.use_active_record_presence can be set to true to infer nullable attributes (i.e. fields that do not specify null: false in schema.rb) as non-null when an unconditional validates :attribute_name, presence: true is present on the model

Relationships

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 to true 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> }

Annotations

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 a Anchor::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.

Descriptions

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.

Generators

This gem provides generators for JSON Schema and TypeScript schemas via Schema.generate(adapter: :type_script | :json_schema).

Custom Generator

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.

Configuration

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> }

Guides

Create a schema serializable resource

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

Anchor::Schema

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::Types

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

Anchor::Types::Object

class CustomPayload < Anchor::Types::Object property :id, Anchor::Types::String, optional: true, description: "ID of payload." end

Anchor::Types::Enum

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.

Example

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", }

References

Security

Security Policy

About

Easily generate TypeScript schemas, JSON Schemas, or any schema of your choice from cerebris/jsonapi-resources JSONAPI::Resource classes.

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •