Skip to content

Commit

Permalink
Merge pull request #1565 from Shopify/seb-json-api-compiler
Browse files Browse the repository at this point in the history
Add a json_api_client DSL compiler
  • Loading branch information
lavoiesl authored Jul 18, 2023
2 parents 9649fc4 + cac38c9 commit d4706d1
Show file tree
Hide file tree
Showing 23 changed files with 8,780 additions and 0 deletions.
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ end

group :development, :test do
gem "smart_properties", require: false
gem "json_api_client", require: false
gem "frozen_record", require: false
gem "sprockets", require: false
gem "rails", require: false
Expand Down
35 changes: 35 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,31 @@ GEM
dry-schema (>= 1.12, < 2)
zeitwerk (~> 2.6)
erubi (1.12.0)
faraday (1.10.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.0)
faraday (~> 1.0)
frozen_record (0.27.0)
activemodel
globalid (1.1.0)
Expand All @@ -173,6 +198,13 @@ GEM
irb (1.6.4)
reline (>= 0.3.0)
json (2.6.3)
json_api_client (1.21.1)
activemodel (>= 3.2.0)
activesupport (>= 3.2.0)
addressable (~> 2.2)
faraday (>= 0.15.2, < 2.0)
faraday_middleware (>= 0.9.0, < 2.0)
rack (>= 0.2)
kramdown (2.4.0)
rexml
kredis (1.5.0)
Expand All @@ -198,6 +230,7 @@ GEM
builder
minitest (>= 5.0)
ruby-progressbar
multipart-post (2.3.0)
net-imap (0.3.6)
date
net-protocol
Expand Down Expand Up @@ -298,6 +331,7 @@ GEM
rubocop-sorbet (0.7.0)
rubocop (>= 0.90.0)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
shopify-money (1.2.1)
sidekiq (7.1.2)
concurrent-ruby (< 2)
Expand Down Expand Up @@ -374,6 +408,7 @@ DEPENDENCIES
google-protobuf
graphql
identity_cache
json_api_client
kramdown (~> 2.4)
kredis
minitest
Expand Down
1 change: 1 addition & 0 deletions gemfiles/Gemfile-rails-6-1
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ end

group(:development, :test) do
gem("smart_properties", require: false)
gem("json_api_client", require: false)
gem("frozen_record", require: false)
gem("sprockets", require: false)
gem("rails", "~> 6.1.0", require: false)
Expand Down
1 change: 1 addition & 0 deletions gemfiles/Gemfile-rails-main
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ end

group(:development, :test) do
gem("smart_properties", require: false)
gem("json_api_client", require: false)
gem("frozen_record", require: false)
gem("sprockets", require: false)
gem("rails", github: "rails/rails", branch: "main", require: false)
Expand Down
208 changes: 208 additions & 0 deletions lib/tapioca/dsl/compilers/json_api_client_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# typed: strict
# frozen_string_literal: true

begin
require "json_api_client"
rescue LoadError
# means JsonApiClient is not installed,
# so let's not even define the compiler.
return
end

module Tapioca
module Dsl
module Compilers
# `Tapioca::Dsl::Compilers::JsonApiClientResource` generates RBI files for classes that inherit
# [`JsonApiClient::Resource`](https://github.com/JsonApiClient/json_api_client).
#
# For example, with the following classes that inherits `JsonApiClient::Resource`:
#
# ~~~rb
# # user.rb
# class User < JsonApiClient::Resource
# has_many :posts
#
# property :name, type: :string
# property :is_admin, type: :boolean, default: false
# end
#
# # post.rb
# class Post < JsonApiClient::Resource
# belongs_to :user
#
# property :title, type: :string
# end
# ~~~
#
# this compiler will produce RBI files with the following content:
#
# ~~~rbi
# # user.rbi
# # typed: strong
#
# class User
# include JsonApiClientResourceGeneratedMethods
#
# module JsonApiClientResourceGeneratedMethods
# sig { returns(T::Boolean) }
# def is_admin; end
#
# sig { params(is_admin: T::Boolean).returns(T::Boolean) }
# def is_admin=(is_admin); end
#
# sig { returns(T.nilable(::String)) }
# def name; end
#
# sig { params(name: T.nilable(::String)).returns(T.nilable(::String)) }
# def name=(name); end
#
# sig { returns(T.nilable(T::Array[Post])) }
# def posts; end
#
# sig { params(posts: T.nilable(T::Array[Post])).returns(T.nilable(T::Array[Post])) }
# def posts=(posts); end
# end
# end
#
# # post.rbi
# # typed: strong
#
# class Post
# include JsonApiClientResourceGeneratedMethods
#
# module JsonApiClientResourceGeneratedMethods
# sig { returns(T.nilable(::String)) }
# def title; end
#
# sig { params(title: T.nilable(::String)).returns(T.nilable(::String)) }
# def title=(title); end
#
# sig { returns(T.nilable(::String)) }
# def user_id; end
#
# sig { params(user_id: T.nilable(::String)).returns(T.nilable(::String)) }
# def user_id=(user_id); end
# end
# end
# ~~~
class JsonApiClientResource < Compiler
extend T::Sig

ConstantType = type_member { { fixed: T.class_of(::JsonApiClient::Resource) } }

sig { override.void }
def decorate
schema = resource_schema
return if schema.nil? && constant.associations.empty?

root.create_path(constant) do |k|
module_name = "JsonApiClientResourceGeneratedMethods"
k.create_module(module_name) do |mod|
schema&.each_property do |property|
generate_methods_for_property(mod, property)
end

constant.associations.each do |association|
generate_methods_for_association(mod, association)
end
end

k.create_include(module_name)
end
end

class << self
extend T::Sig

sig { override.returns(T::Enumerable[Module]) }
def gather_constants
all_modules.select do |c|
name_of(c) && c < ::JsonApiClient::Resource
end
end
end

private

sig { returns(T.nilable(::JsonApiClient::Schema)) }
def resource_schema
schema = constant.schema

# empty? does not exist on JsonApiClient::Schema
schema if schema.size > 0 # rubocop:disable Style/ZeroLengthPredicate
end

sig do
params(
mod: RBI::Scope,
property: ::JsonApiClient::Schema::Property,
).void
end
def generate_methods_for_property(mod, property)
type = type_for(property)

name = property.name.to_s

mod.create_method(name, return_type: type)
mod.create_method("#{name}=", parameters: [create_param(name, type: type)], return_type: type)
end

sig { params(property: ::JsonApiClient::Schema::Property).returns(String) }
def type_for(property)
type = ::JsonApiClient::Schema::TypeFactory.type_for(property.type)
return "T.untyped" if type.nil?

sorbet_type = if type.respond_to?(:sorbet_type)
type.sorbet_type
elsif type == ::JsonApiClient::Schema::Types::Integer
"::Integer"
elsif type == ::JsonApiClient::Schema::Types::String
"::String"
elsif type == ::JsonApiClient::Schema::Types::Float
"::Float"
elsif type == ::JsonApiClient::Schema::Types::Time
"::Time"
elsif type == ::JsonApiClient::Schema::Types::Decimal
"::BigDecimal"
elsif type == ::JsonApiClient::Schema::Types::Boolean
"T::Boolean"
else
"T.untyped"
end

if property.default.nil?
as_nilable_type(sorbet_type)
else
sorbet_type
end
end

sig do
params(
mod: RBI::Scope,
association: JsonApiClient::Associations::BaseAssociation,
).void
end
def generate_methods_for_association(mod, association)
# If the association is broken, it will raise a NameError when trying to access the association_class
klass = association.association_class

name, type = case association
when ::JsonApiClient::Associations::BelongsTo::Association
# id must be a string: # https://jsonapi.org/format/#document-resource-object-identification
[association.param.to_s, "T.nilable(::String)"]
when ::JsonApiClient::Associations::HasOne::Association
[association.attr_name.to_s, "T.nilable(#{klass})"]
when ::JsonApiClient::Associations::HasMany::Association
[association.attr_name.to_s, "T.nilable(T::Array[#{klass}])"]
else
return # Unsupported association type
end

mod.create_method(name, return_type: type)
mod.create_method("#{name}=", parameters: [create_param(name, type: type)], return_type: type)
end
end
end
end
end
75 changes: 75 additions & 0 deletions manual/compiler_jsonapiclientresource.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
## JsonApiClientResource

`Tapioca::Dsl::Compilers::JsonApiClientResource` generates RBI files for classes that inherit
[`JsonApiClient::Resource`](https://github.com/JsonApiClient/json_api_client).

For example, with the following classes that inherits `JsonApiClient::Resource`:

~~~rb
# user.rb
class User < JsonApiClient::Resource
has_many :posts

property :name, type: :string
property :is_admin, type: :boolean, default: false
end

# post.rb
class Post < JsonApiClient::Resource
belongs_to :user

property :title, type: :string
end
~~~

this compiler will produce RBI files with the following content:

~~~rbi
# user.rbi
# typed: strong

class User
include JsonApiClientResourceGeneratedMethods

module JsonApiClientResourceGeneratedMethods
sig { returns(T::Boolean) }
def is_admin; end

sig { params(is_admin: T::Boolean).returns(T::Boolean) }
def is_admin=(is_admin); end

sig { returns(T.nilable(::String)) }
def name; end

sig { params(name: T.nilable(::String)).returns(T.nilable(::String)) }
def name=(name); end

sig { returns(T.nilable(T::Array[Post])) }
def posts; end

sig { params(posts: T.nilable(T::Array[Post])).returns(T.nilable(T::Array[Post])) }
def posts=(posts); end
end
end

# post.rbi
# typed: strong

class Post
include JsonApiClientResourceGeneratedMethods

module JsonApiClientResourceGeneratedMethods
sig { returns(T.nilable(::String)) }
def title; end

sig { params(title: T.nilable(::String)).returns(T.nilable(::String)) }
def title=(title); end

sig { returns(T.nilable(::String)) }
def user_id; end

sig { params(user_id: T.nilable(::String)).returns(T.nilable(::String)) }
def user_id=(user_id); end
end
end
~~~
1 change: 1 addition & 0 deletions manual/compilers.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ In the following section you will find all available DSL compilers:
* [GraphqlInputObject](compiler_graphqlinputobject.md)
* [GraphqlMutation](compiler_graphqlmutation.md)
* [IdentityCache](compiler_identitycache.md)
* [JsonApiClientResource](compiler_jsonapiclientresource.md)
* [Kredis](compiler_kredis.md)
* [MixedInClassAttributes](compiler_mixedinclassattributes.md)
* [Protobuf](compiler_protobuf.md)
Expand Down
Loading

0 comments on commit d4706d1

Please sign in to comment.