Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a json_api_client DSL compiler #1565

Merged
merged 3 commits into from
Jul 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Comment on lines +131 to +132
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disabled rubocop rule because it was a false positive

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
lavoiesl marked this conversation as resolved.
Show resolved Hide resolved

if property.default.nil?
as_nilable_type(sorbet_type)
Comment on lines +173 to +174
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Properties with default values are always initialized with their default value, even when creating a bare object.

We made the decision to make those properties non-nilable to make the usage cleaner.

That being said, it is possible today to have a nil:

class User < JsonApiClient::Resource
  property :name, type: :string, default: "foo"
end

> u = User.new
=> #<User:@attributes={"type"=>"users", "name"=>"foo"}>

> u.name
=> "foo"

> u.name = 123
=> 123

> u.name # Casts to string
=> "123"

> u.name = nil

> u.name # Actually nil
=> nil

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we rescue and rethrow a better error telling the user that the association is broken?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what I wanted to do at first, but @Morriar 's argument was that this would show a stack within the gem, with a full stack trace, instead of us catching it. It's a problem of the user misusing the gem, not misusing tapioca/sorbet.

Another point is that this is forward compatible. If the gem changes its implementation, we don't have to change anything.

I'm okay either way, just highlighting the discussion.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a way for compilers to signal errors that we use in AR associations compiler, for example:

rescue SourceReflectionError
add_error(<<~MSG.strip)
Cannot generate association `#{reflection.name}` on `#{constant}` since the source of the through association is missing.
MSG
rescue MissingConstantError => error
add_error(<<~MSG.strip)
Cannot generate association `#{declaration(reflection)}` on `#{constant}` since the constant `#{error.class_name}` does not exist.
MSG
end

klass = association.association_class
Comment on lines +187 to +188
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a test for this


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}])"]
Comment on lines +191 to +197
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, all of those things can be nullable when an object is created without being loaded via the API

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