-
Notifications
You must be signed in to change notification settings - Fork 122
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||||||||||||||||||||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: tapioca/lib/tapioca/dsl/compilers/active_record_associations.rb Lines 168 to 176 in bc6805e
|
||||||||||||||||||||
klass = association.association_class | ||||||||||||||||||||
Comment on lines
+187
to
+188
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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 | ||
~~~ |
There was a problem hiding this comment.
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