diff --git a/.gitignore b/.gitignore index af00795..15391f1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ # rspec failure tracking .rspec_status -*.gem \ No newline at end of file +*.gem + +.idea \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index fbbb4a2..ca6fa7c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -108,10 +108,28 @@ Metrics/AbcSize: Metrics/MethodLength: Enabled: false Metrics/CyclomaticComplexity: - Max: 15 + Enabled: false Metrics/PerceivedComplexity: - Max: 15 + Enabled: false Lint/DuplicateMethods: # Disables duplicate methods warning Enabled: false Gemspec/RequiredRubyVersion: # Disables required ruby version warning Enabled: false +Metrics/ParameterLists: # Disables parameter lists warning + Enabled: false +Lint/NextWithoutAccumulator: # Disables next without accumulator warning + Enabled: false +Lint/ShadowingOuterLocalVariable: # Disables shadowing outer local variable warning + Enabled: false +Metrics/ModuleLength: # Disables module length warning + Enabled: false +Layout/EmptyLinesAroundClassBody: # Disables empty lines around class body warning + Enabled: false +Layout/HeredocIndentation: # Disables heredoc indentation warning + Enabled: false +Layout/ClosingHeredocIndentation: # Disables closing heredoc indentation warning + Enabled: false +Rails/Output: # Disables rails output warning + Enabled: false +Metrics/ClassLength: # Disables class length warning + Max: 150 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2027bad..ea1443b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ -## [Unreleased] +# Changelog +This file is used to list changes made in each version of the Schemable gem. -## [0.1.0] - 2023-05-10 +## Schemable 1.0.3 (2024-01-30) -- Initial release +* Added configuration for preventing expansion for nested relationships. This can be done by setting the `expand_nested` to `true` when invoking `ResponseSchemaGenerator`'s `generate` instance method (e.g. `ResponseSchemaGenerator.new(instance).generate(expand: true, expand_nested: true)`. Additionally, you could globally set the value of `expand_nested` to the same value as `expand` by setting the configuration `infer_expand_nested_from_expand` to `true` in the `/config/initializers/schemable.rb`. + +## Schemable 1.0.2 (2024-01-30) + +* Added configuration for making certain associations nullable in the response's relationship. This can be done by adding the name of the relation in the `nullable_relationships` method's array of strings. + +## Schemable 1.0.1 (2024-01-29) + +* Added configuration for changing the default value of enums. By default first key is used, or alternatively default can be set manually by the method `default_value_for_enum_attributes` from the definition. + +## Schemable 1.0.0 (2023-11-17) + +* Initial release diff --git a/Gemfile b/Gemfile index 0014dd4..69bbf95 100644 --- a/Gemfile +++ b/Gemfile @@ -1,14 +1,15 @@ # frozen_string_literal: true -source "https://rubygems.org" +source 'https://rubygems.org' gemspec -gem "rake", "~> 13.0" -gem "rspec", "~> 3.0" -gem "rubocop", "~> 1.21" +gem 'rake', '~> 13.1.0' +gem 'rspec', '~> 3.12.0' +gem 'rubocop', '~> 1.60.2' +gem 'rubocop-rails', '~> 2.23.1' group :development, :test do + gem 'factory_bot_rails', '~> 6.4.3' gem 'jsonapi-rails', '~> 0.4.1' - gem 'factory_bot_rails', '~> 6.2' -end \ No newline at end of file +end diff --git a/Gemfile.lock b/Gemfile.lock index a582af0..e69de29 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,128 +0,0 @@ -PATH - remote: . - specs: - schemable (0.1.2) - factory_bot_rails (~> 6.2.0) - jsonapi-rails (~> 0.4.1) - -GEM - remote: https://rubygems.org/ - specs: - actionpack (7.0.4.3) - actionview (= 7.0.4.3) - activesupport (= 7.0.4.3) - rack (~> 2.0, >= 2.2.0) - rack-test (>= 0.6.3) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.0, >= 1.2.0) - actionview (7.0.4.3) - activesupport (= 7.0.4.3) - builder (~> 3.1) - erubi (~> 1.4) - rails-dom-testing (~> 2.0) - rails-html-sanitizer (~> 1.1, >= 1.2.0) - activesupport (7.0.4.3) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) - ast (2.4.2) - builder (3.2.4) - concurrent-ruby (1.2.2) - crass (1.0.6) - diff-lcs (1.5.0) - erubi (1.12.0) - factory_bot (6.2.1) - activesupport (>= 5.0.0) - factory_bot_rails (6.2.0) - factory_bot (~> 6.2.0) - railties (>= 5.0.0) - i18n (1.13.0) - concurrent-ruby (~> 1.0) - json (2.6.3) - jsonapi-deserializable (0.2.0) - jsonapi-parser (0.1.1) - jsonapi-rails (0.4.1) - jsonapi-parser (~> 0.1.0) - jsonapi-rb (~> 0.5.0) - jsonapi-rb (0.5.0) - jsonapi-deserializable (~> 0.2.0) - jsonapi-serializable (~> 0.3.0) - jsonapi-renderer (0.2.2) - jsonapi-serializable (0.3.1) - jsonapi-renderer (~> 0.2.0) - loofah (2.21.2) - crass (~> 1.0.2) - nokogiri (>= 1.12.0) - method_source (1.0.0) - minitest (5.18.0) - nokogiri (1.14.4-x86_64-linux) - racc (~> 1.4) - parallel (1.23.0) - parser (3.2.2.1) - ast (~> 2.4.1) - racc (1.6.2) - rack (2.2.7) - rack-test (2.1.0) - rack (>= 1.3) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) - nokogiri (>= 1.6) - rails-html-sanitizer (1.5.0) - loofah (~> 2.19, >= 2.19.1) - railties (7.0.4.3) - actionpack (= 7.0.4.3) - activesupport (= 7.0.4.3) - method_source - rake (>= 12.2) - thor (~> 1.0) - zeitwerk (~> 2.5) - rainbow (3.1.1) - rake (13.0.6) - regexp_parser (2.8.0) - rexml (3.2.5) - rspec (3.12.0) - rspec-core (~> 3.12.0) - rspec-expectations (~> 3.12.0) - rspec-mocks (~> 3.12.0) - rspec-core (3.12.2) - rspec-support (~> 3.12.0) - rspec-expectations (3.12.3) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-mocks (3.12.5) - diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.12.0) - rspec-support (3.12.0) - rubocop (1.51.0) - json (~> 2.3) - parallel (~> 1.10) - parser (>= 3.2.0.0) - rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) - ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.28.1) - parser (>= 3.2.1.0) - ruby-progressbar (1.13.0) - thor (1.2.2) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - unicode-display_width (2.4.2) - zeitwerk (2.6.8) - -PLATFORMS - x86_64-linux - -DEPENDENCIES - factory_bot_rails (~> 6.2) - jsonapi-rails (~> 0.4.1) - rake (~> 13.0) - rspec (~> 3.0) - rubocop (~> 1.21) - schemable! - -BUNDLED WITH - 2.4.12 diff --git a/README.md b/README.md index e7ae401..e8c3a4f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Schemable -The Schemable gem provides a simple way to define a schema for a Rails model in [JSONAPI](https://jsonapi.org/) format. It can automatically generate a schema for a model based on the model's factory and the model's attributes. It is also highly customizable, allowing you to modify the schema to your liking by overriding the default methods. +The Schemable gem provides a simple way to define a schema for a Rails model in [JSONAPI](https://jsonapi.org/) format. It can automatically generate a schema for a model based on the model's attributes. It is also highly customizable, allowing you to modify the schema to your liking by overriding configuration options and methods. + +This gem is preferably to be used with [RSwag](https://github.com/rswag/rswag) gem to generate the swagger documentation for your API. ## Installation @@ -12,25 +14,24 @@ gem 'schemable' And then execute: - $ bundle install + bundle install Or install it yourself as: - $ gem install schemable + gem install schemable ## Usage -The installation command above will install the Schemable gem and its dependencies. However, in order for Schemable to work, you must also add the following files to your Rails application: - -- `app/helpers/serializers_helper.rb` - This file contains the `serializers_map` helper method, which is used to map a model to its serializer. -- `spec/swagger/common_definitions.rb` - This file contains the `aggregate` method, which is used to aggregate the schemas of all the models in your application into a single place. This file is recommeded, but not required. If you do not use this file, you will need to manually aggregate the schemas of all the models in your application into a single place. +The installation command above will install the Schemable gem and its dependencies. However, in order for Schemable to work, you must also implement your own logic to use the generated schemas to feed it to RSwag. -To generate these files, run the following command: +The below command is to initialize the gem and generate the configuration file. ```ruby rails g schemable:install ``` +This will generate `schemable.rb` in your `config/initializers` directory. This file will contain the configuration for the Schemable gem. You can modify the configuration to your liking. For more information on the configuration options, see the [Configuration](#configuration) section below. + ### Generating Definition Files The Schemable gem provides a generator that can be used to generate definition files for your models. To generate a definition file for a model, run the following command: @@ -41,98 +42,319 @@ rails g schemable:model --model_name This will generate a definition file for the specified model in the `lib/swagger/definitions` directory. The definition file will be named `.rb`. This file will have the bare minimum code required to generate a schema for the model. You can then modify the definition file to your liking by overriding the default methods. For example, you can add or remove attributes from the schema, or you can add or remove relationships from the schema. You can also add custom attributes to the schema. For more information on how to customize the schema, see the [Customizing the Schema](#customizing-the-schema) section below. -## Customizing the Schema - -The Schemable gem provides a number of methods that can be used to customize the schema. These methods are defined in the `Schemable` module of the gem. To customize the schema, simply override the default methods in the definition file for the model. The following is a list of the methods that can be overridden: - -| WARNING: please read the method inline documentation before overriding to avoid any unexpected behavior. | -| -------------------------------------------------------------------------------------------------------- | - -The list of methods that can be overridden are as follows: - -| Method Name | Description | -| -------------------------------- | ---------------------------------------------------------------------------------------------------------- | -| `serializer` | Returns the serializer class. | -| `attributes` | Returns the attributes that are auto generated from the model. | -| `relationships` | Returns the relationships in the format of { belongs_to: {}, has_many: {} }. | -| `array_type` | Returns the type of arrays in the model that needs to be manually defined. | -| `optional_request_attributes` | Returns the attributes that are optional in the request schema. | -| `nullable_attributes` | Returns the attributes that are nullable in the request/response schema. | -| `additional_request_attributes` | Returns the attributes that are additional in the request schema. | -| `additional_response_attributes` | Returns the attributes that are additional in the response schema. | -| `additional_response_relations` | Returns the relationships that are additional in the response schema (Appended to relationships). | -| `additional_response_included` | Returns the included that are additional in the response schema (Appended to included). | -| `excluded_request_attributes` | Returns the attributes that are excluded from the request schema. | -| `excluded_response_attributes` | Returns the attributes that are excluded from the response schema. | -| `excluded_response_relations` | Returns the relationships that are excluded from the response schema. | -| `excluded_response_included` | (not implemented yet) Returns the included that are excluded from the response schema. | -| `nested_relationships` | Returns the relationships to be further expanded in the response schema. | -| `model` | Returns the model class (Constantized from the definition class name). | -| `model_name` | Returns the model name. Used for schema type naming. | -| `definitions` | Returns the generated schemas in JSONAPI format (It is recommended to override this method to your liking) | - -The following is an example of a definition file for a model that has been customized: - -
-Click to view the example +### Configuration + +The Schemable gem provides a number of configuration options that can be used to customize the behavior of the gem. The following is a list of the configuration options that are available. + +Please note that the configurations options below are defined in the `Schemable` module of the gem. To configure the gem, simply override the default values in the `config/initializers/schemable.rb` file. Also the changes will affect all the definition classes globally. + +--- + +| Option Name | Description | Default Value | +| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `orm` | The ORM that is used in the application. The options are `:active_record` and `:mongoid`. | `true` | +| `float_as_string` | Whether or not to convert the `float` type to a `string` type in the schema. | `false` | +| `decimal_as_string` | Whether or not to convert the `decimal` type to a `string` type in the schema. | `false` | +| `custom_type_mappers` | A hash of custom type mappers that can be used to override the default type mappers. A specific method should be used, see [Annex 1.0 - Add custom type mapper](#annex-10---add-custom-type-mapper) for more information. | `{}` | +| `use_serialized_instance` | Whether or not to use the serialized instance in the process of schema generation as type fallback for virtual attributes. See [Annex 1.1 - Use serialized instance](#annex-11---use-serialized-instance) for more information. | `false` | +| `custom_defined_enum_method` | The name of the method that is used to get the enum keys and values. This allows applications with the orm `mongoid` define a method that mimicks what `defined_enums` does in `activerecord`. Please see [Annex 1.2 - Custom defined enum method](#annex-12---custom-defined-enum-method) for an example. | `nil` | +| `enum_prefix_for_simple_enum` | The prefix to be used for the enum values when `mongoid` is used. | `nil` | +| `enum_suffix_for_simple_enum` | The suffix to be used for the enum values when `mongoid` is used. | `nil` | +| `infer_attributes_from_custom_method` | The name of the custom method that is used to get the attributes to be generated in the schema. See [Annex 1.3 - Infer attributes from custom method](#annex-13---infer-attributes-from-custom-method) for more information. | `nil` | +| `infer_expand_nested_from_expand` | Configures `ResponseSchemaGenerator`'s `generate` method to prevent expansion for nested relationships. It globally set the value of `expand_nested` to the same value as `expand` by setting the configuration to `true` | `flase` | +| `infer_attributes_from_jsonapi_serializable` | Whether or not to infer the attributes from the `JSONAPI::Serializable::Resource` class. See the previous example [Annex 1.1 - Use serialized instance](#annex-11---use-serialized-instance) for more information. | `false` | +| `custom_meta_response_schema` | A hash of custom meta response schema that can be used to override the default meta response schema. See [Annex 1.4 - Custom meta response schema](#annex-14---custom-meta-response-schema) for more information. | `nil` | +| `pagination_enabled` | Enable pagination schema generation in the `meta` section of the response schema. | `true` | + +--- + +### Customizing the Schema + +The Schemable gem provides a number of methods that can be used to customize the schema. These methods are defined in the `Schemable::Definition` class of the gem. To customize the schema for a specific model, simply override the default methods in the `Schemable::Definition` class for the model. + +Please read the method inline documentation before overriding to avoid any unexpected behavior. + +The following is a list of the methods that can be overridden. (See the example in [Annex 1.5 - Highly Customized Definition](#annex-15---highly-customized-definition) for a highly customized definition file.) + +--- + +| Method Name | Description | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `serializer` | Returns the serializer of the model for the definition. | +| `attributes` | Returns the attributes for the definition based on the configuration. | +| `relationships` | Returns the relationships defined in the model. | +| `array_types` | Returns a hash of all the arrays defined for the model. | +| `optional_create_request_attributes` | Returns the attributes that are not required in the create request. | +| `optional_update_request_attributes` | Returns the attributes that are not required in the update request. | +| `nullable_attributes` | Returns the attributes that are nullable in the request/response body. | +| `nullable_relationships` | Returns the relationships that are nullable in the response body. | +| `additional_create_request_attributes` | Returns the additional create request attributes that are not automatically generated. | +| `additional_update_request_attributes` | Returns the additional update request attributes that are not automatically generated. | +| `additional_response_attributes` | Returns the additional response attributes that are not automatically generated. | +| `additional_response_relations` | Returns the additional response relations that are not automatically generated. | +| `additional_response_included` | Returns the additional response included that are not automatically generated. | +| `excluded_create_request_attributes` | Returns the attributes that are excluded from the create request schema. | +| `excluded_update_request_attributes` | Returns the attributes that are excluded from the update request schema. | +| `default_value_for_enum_attributes` | Returns the default value for the enum attributes. Used when you want a custom value for the default enum. By default the first key is used as default | +| `excluded_response_attributes` | Returns the attributes that are excluded from the response schema. | +| `excluded_response_relations` | Returns the relationships that are excluded from the response schema. | +| `excluded_response_included` | Returns the included that are excluded from the response schema. | +| `serialized_instance` | Returns an instance of the model class that is already serialized into jsonapi format. | +| `model` | Returns the model class (Constantized from the definition class name). | +| `model_name` | Returns the model name. Used for schema type naming. | +| `camelize_keys` | Given a hash, it returns a new hash with all the keys camelized. | +| `generate` | Returns the schema for the create request body, update request body, and response body. | + +--- + +## Examples + +The followings are some examples of configuration of the gem to have different behaviors based on the application needs. In the above section, we have already seen how to generate the definition files for the models. The following examples will show how to customize the schema for the models. Also, we will see how to use the generated schema in RSwag to generate the swagger documentation for the API. + +### Annex 1.0 - Add custom type mapper ```ruby -module Swagger - module Definitions - class UserApplication +# config/initializers/schemable.rb + +Schemable.configure do |config| + config.add_custom_type_mapper( + :string, + { type: :text } + ) +end + +``` + +### Annex 1.1 - Use serialized instance + +```ruby +# config/initializers/schemable.rb + +Schemable.configure do |config| + config.use_serialized_instance = true +end +``` + +Then in the definition file, you can override the `serialized_instance` method to return the serialized instance of the model. Let's also assume that we want to use the `JSONAPI::Serializable::Resource` class to serialize the instance. - include Schemable - include SerializersHelper +```ruby +# lib/swagger/definitions/user_application.rb +module Swagger + module Definitions + class User < Schemable::Definition attr_accessor :instance - def initialize - @instance ||= JSONAPI::Serializable::Renderer.new.render(FactoryBot.create(:user, :with_user_application_applicants), class: serializers_map, include: []) + def serializer + V1::UserSerializer end - def serializer - V1::UserApplicationSerializer + def serialized_instance + @instance ||= JSONAPI::Serializable::Renderer.new.render( + FactoryBot.create(:user), + class: { User: serializer }, + include: [] + ) + end + end + end +end +``` + +### Annex 1.2 - Custom defined enum method + +Let's assume that we also want to use mongoid in our application. In this case, we need to define a method that mimicks what `defined_enums` does in `activerecord`. Let's assume that we have a model called `User` that has an enum field called `status`. The following is an example of how to define the method: + +```ruby +# app/models/user.rb + +class User < ApplicationModel + include Mongoid::Document + include SimpleEnum::Mongoid + + as_enum :status, active: 0, inactive: 1 +end +``` + +Then in the `ApplicationModel` class, we can define the method as follows: + +```ruby +# app/models/application_model.rb + +def self.custom_defined_enum(suffix: '_cd', prefix: nil) + defined_enums = {} + enum_fields = if prefix + fields.select { |k, v| k.to_s.start_with?(prefix) } + else + fields.select { |k, v| k.to_s.end_with?(suffix) } + end + + enum_fields.each do |k, v| + enum_name = k.to_s.gsub(prefix || suffix, '') + enum = send(enum_name.pluralize) + + defined_enums[enum_name] = enum.hash + end + + defined_enums +end +``` + +This method will work for all the models that have enum fields. Since Simple Enum gem defines enum fields with the suffix `_cd`, we can use the `suffix` option to get the enum fields. However, if the enum fields are defined with a different suffix, we can use the `prefix` option to get the enum fields. + +Now, we need to specify theses options in the configuration file: + +```ruby +# config/initializers/schemable.rb + +Schemable.configure do |config| + config.custom_defined_enum_method = :custom_defined_enum + config.enum_suffix_for_simple_enum = '_cd' +end +``` + +This will generate the schema for the enum fields in the model as follows: + +```ruby +{ + # ... + status: { + type: string, + enum: ['active', 'inactive'] + } + # ... +} +``` + +### Annex 1.3 - Infer attributes from custom method + +Sometimes, we may want to infer the attributes from a custom method. For example, let's assume that we have a model called `User` that has a method called `base_attributes` that returns an array of attributes. The following is an example of how to define the method: + +```ruby +# app/models/user.rb + +class User < ApplicationModel + def self.base_attributes + %i[ + id + name + email + status + roles + created_at + updated_at + ] + end +end +``` + +Then in the configuration file, we can override the `infer_attributes_from_custom_method` method to return the attributes from the custom method: + +```ruby +# config/initializers/schemable.rb + +Schemable.configure do |config| + config.infer_attributes_from_custom_method = :base_attributes +end +``` + +if we want to use the `base_attributes` method for only the User model, we can override the `attributes` method in the `Schemable::Definition` class as follows: + +```ruby +# lib/swagger/definitions/user.rb + +module Swagger + module Definitions + class User < Schemable::Definition + def attributes + model.base_attributes end + end + end +end +``` + +### Annex 1.4 - Custom meta response schema +Sometimes, we may want to customize the meta response schema. For example, let's assume that we want to add a `total` attribute to the meta response schema. The following is an example of how to do that: + +```ruby +# config/initializers/schemable.rb + +Schemable.configure do |config| + config.custom_meta_response_schema = { + type: :object, + properties: { + total: { type: :integer } + } + } +end +``` + +### Annex 1.5 - Highly Customized Definition + +The below is a definition file for a model that has been customized in a way that many of the methods have been overridden. This is just an example of how to customize the schema. You can customize the schema to your liking. The below hypothetical model's logic does not matter. The only thing that matters is the schema customization and being familiar with the methods that can be overridden. + + +
+ Click to expand + +```ruby +module Swagger + module Definitions + class Order < Schemable::Definition def relationships - { + @relationships ||= { belongs_to: { - category: Swagger::Definitions::Category, + address: Swagger::Definitions::Address.new }, has_many: { - applicants: Swagger::Definitions::Applicant, + items: Swagger::Definitions::Item.new + }, + addition_to_included: { + store: Swagger::Definitions::Store.new, + attachments: Swagger::Definitions::Upload.new } } end - def array_types - { - applicant_ids: + def excluded_create_request_attributes + create_params = model.create_params.select { |item| item.is_a?(Symbol) } + model.base_attributes + %i[comment applicable_transitions] - create_params + end + + def excluded_update_request_attributes + update_params = model.update_params.select { |item| item.is_a?(Symbol) } + model.base_attributes + %i[comment applicable_transitions] - update_params + end + + def additional_create_request_attributes + @additional_create_request_attributes ||= { + items_attributes: { type: :array, - items: - { - type: :string - }, - nullable: true + items: { + anyOf: [ + { + type: :object, + properties: Schemable::RequestSchemaGenerator.new(Swagger::Definitions::Item.new).generate_for_create.as_json['properties']['data']['properties'] + } + ] + } } } end - def excluded_request_attributes - %i[id updated_at created_at applicant_ids comment] - end - - def additional_request_attributes - { - applicants_attributes: + def additional_update_request_attributes + @additional_update_request_attributes ||={ + items_attributes: { type: :array, items: { anyOf: [ { type: :object, - properties: Swagger::Definitions::Applicant.new.request_schema.as_json['properties']['data']['properties'] + properties:Schemable::RequestSchemaGenerator.new(Swagger::Definitions::Item.new).generate_for_update.as_json['properties']['data']['properties'] } ] } @@ -142,54 +364,60 @@ module Swagger def additional_response_attributes { - comment: { type: :object, properties: {}, nullable: true } + comment: { type: :object, properties: {}, nullable: true }, + item_status: { type: :string, nullable: true }, + applicable_transitions: { + type: :array, + items: + { + type: :object, nullable: true, + properties: + { + name: { type: :string, nullable: true }, + metadata: { type: :object, nullable: true } + } + }, + nullable: true + } } end - def nested_relationships - { - applicants: { - belongs_to: { - district: Swagger::Definitions::District, - province: Swagger::Definitions::Province, - }, - has_many: { - attachments: Swagger::Definitions::Upload, - } - } - } + def nullable_attributes + %i[contact_number email] + end + + def optional_create_request_attributes + %i[contact_number email] + end + + def optional_update_request_attributes + %i[contact_number email] end - def self.definitions - schema_instance = self.new + def self.generate + schema_instance = new + [ - "#{schema_instance.model}Request": schema_instance.camelize_keys(schema_instance.request_schema), - "#{schema_instance.model}Response": schema_instance.camelize_keys(schema_instance.response_schema(expand: true, exclude_from_expansion: [:category], multi: true)), - "#{schema_instance.model}ResponseExpanded": schema_instance.camelize_keys(schema_instance.response_schema(expand: true, nested: true)) + "#{schema_instance.model}CreateRequest": schema_instance.camelize_keys(Schemable::RequestSchemaGenerator.new(schema_instance).generate_for_create), + "#{schema_instance.model}UpdateRequest": schema_instance.camelize_keys(Schemable::RequestSchemaGenerator.new(schema_instance).generate_for_update), + "#{schema_instance.model}Response": schema_instance.camelize_keys(Schemable::ResponseSchemaGenerator.new(schema_instance).generate(expand: true, collection: true, relationships_to_exclude_from_expansion: %w[addresses stores attachments])), + "#{schema_instance.model}ResponseExpanded": schema_instance.camelize_keys(Schemable::ResponseSchemaGenerator.new(schema_instance).generate(expand: true, expand_nested: true)) ] end end end end - ``` -
- -## Development - -After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. - -To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). +
+ ## Contributing -Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/schemable. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/schemable/blob/master/CODE_OF_CONDUCT.md). +Bug reports and pull requests are welcome on GitHub at . This project is intended to be a safe, welcoming space for collaboration, and contributors. Please go to issues page to report any bugs or feature requests. If you would like to contribute, please fork the repository and submit a pull request. + +To, use the gem locally, clone the repository and run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests. ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). - -## Code of Conduct - -Everyone interacting in the Schemable project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/schemable/blob/master/CODE_OF_CONDUCT.md). diff --git a/Rakefile b/Rakefile index cca7175..4964751 100644 --- a/Rakefile +++ b/Rakefile @@ -1,11 +1,11 @@ # frozen_string_literal: true -require "bundler/gem_tasks" -require "rspec/core/rake_task" +require 'bundler/gem_tasks' +require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) -require "rubocop/rake_task" +require 'rubocop/rake_task' RuboCop::RakeTask.new diff --git a/lib/generators/schemable/install_generator.rb b/lib/generators/schemable/install_generator.rb index 101de4a..33d9af4 100644 --- a/lib/generators/schemable/install_generator.rb +++ b/lib/generators/schemable/install_generator.rb @@ -1,28 +1,18 @@ module Schemable class InstallGenerator < Rails::Generators::Base - source_root File.expand_path('../../templates', __dir__) - class_option :model_name, type: :string, default: 'Model', desc: 'Name of the model' - def initialize(*) - super(*) + def initialize(*args) + super(*args) end def copy_initializer - target_path = 'spec/swagger/common_definitions.rb' - - if Rails.root.join(target_path).exist? - say_status('skipped', 'Common definitions already exists') - else - copy_file('common_definitions.rb', target_path) - end - - target_path = 'app/helpers/serializers_helper.rb' + target_path = 'config/initializers/schemable.rb' if Rails.root.join(target_path).exist? - say_status('skipped', 'Serializers helper already exists') + say_status('skipped', 'Schemable initializer already exists') else - copy_file('serializers_helper.rb', target_path) + copy_file('schemable.rb', target_path) end end end diff --git a/lib/generators/schemable/model_generator.rb b/lib/generators/schemable/model_generator.rb index 3c4b05f..37b3d7b 100644 --- a/lib/generators/schemable/model_generator.rb +++ b/lib/generators/schemable/model_generator.rb @@ -4,8 +4,8 @@ class ModelGenerator < Rails::Generators::Base source_root File.expand_path('../../templates', __dir__) class_option :model_name, type: :string, default: 'Model', desc: 'Name of the model' - def initialize(*) - super(*) + def initialize(*args) + super(*args) @model_name = options[:model_name] @model_name != 'Model' || raise('Model name is required') @@ -21,30 +21,19 @@ def copy_initializer create_file(target_path, <<-FILE module Swagger module Definitions - class #{@model_name.classify} - - include Schemable - include SerializersHelper # This is a helper module that contains a method "serializers_map" that maps models to serializers - - attr_accessor :instance - - def initialize - @instance ||= JSONAPI::Serializable::Renderer.new.render(FactoryBot.create(:#{@model_name.underscore.downcase.singularize}), class: serializers_map, include: []) + class #{@model_name.classify} < Schemable::Definition + def excluded_create_request_attributes + %i[updated_at created_at] end - def serializer - V1::#{@model_name.classify}Serializer - end - - def excluded_request_attributes - %i[id updatedAt createdAt] + def excluded_update_request_attributes + %i[updated_at created_at] end end end end FILE ) - end end end diff --git a/lib/schemable.rb b/lib/schemable.rb index 8d5c628..ea02382 100644 --- a/lib/schemable.rb +++ b/lib/schemable.rb @@ -1,869 +1,69 @@ -# frozen_string_literal: true - -require_relative "schemable/version" -require 'active_support/concern' - +require_relative 'schemable/version' +require_relative 'schemable/definition' +require_relative 'schemable/configuration' +require_relative 'schemable/schema_modifier' +require_relative 'schemable/attribute_schema_generator' +require_relative 'schemable/relationship_schema_generator' +require_relative 'schemable/included_schema_generator' +require_relative 'schemable/response_schema_generator' +require_relative 'schemable/request_schema_generator' + +# The Schemable module provides a set of classes and methods for generating and modifying schemas in JSONAPI format. +# It includes classes for generating attribute, relationship, included, response, and request schemas. +# It also provides a configuration class for setting up the module's behavior. +# +# @example +# The following example shows how to use the Schemable module to generate a schema for a Comment model. +# +# # config/initializers/schemable.rb +# Schemable.configure do |config| +# #... chosen configuration options ... +# end +# +# # lib/swagger/definitions/comment.rb +# class Swagger::Definitions::Comment < Schemable::Definition; end +# +# # whenever you need to generate the schema for a Comment model. +# # i.e. in RSwag's swagger_helper.rb +# +# # spec/swagger_helper.rb +# # ... +# RSpec.configure do |config| +# +# config.swagger_docs = { +# # ... +# components: { +# # ... +# schemas: Swagger::Definitions::Comment.generate.flatten.reduce({}, :merge) +# # ... +# } +# # ... +# } +# # ... +# end +# +# @see Schemable::Definition +# @see Schemable::Configuration +# @see Schemable::SchemaModifier +# @see Schemable::AttributeSchemaGenerator +# @see Schemable::RelationshipSchemaGenerator +# @see Schemable::IncludedSchemaGenerator +# @see Schemable::ResponseSchemaGenerator +# @see Schemable::RequestSchemaGenerator module Schemable + # Error class for handling exceptions specific to the Schemable module. class Error < StandardError; end - extend ActiveSupport::Concern - - included do - - # Maps a given type name to a corresponding JSON schema object that represents that type. - # - # @param type_name [String, Symbol] A String or Symbol representing the type of the property to be mapped. - # - # @return [Hash, nil] A Hash that represents a JSON schema object for the given type, or nil if the type is not recognized. - def type_mapper(type_name) - { - text: { type: :string }, - string: { type: :string }, - integer: { type: :integer }, - float: { type: :number, format: :float }, - decimal: { type: :number, format: :double }, - datetime: { type: :string, format: :"date-time" }, - date: { type: :string, format: :date }, - time: { type: :string, format: :time }, - boolean: { type: :boolean }, - trueclass: { type: :boolean, default: true }, - falseclass: { type: :boolean, default: false }, - binary: { type: :string, format: :binary }, - json: { type: :object, properties: {} }, - jsonb: { type: :object, properties: {} }, - array: { type: :array, items: { anyOf: [ - { type: :string }, { type: :integer }, { type: :number, format: :float }, { type: :number, format: :double }, { type: :boolean }, { type: :object, properties: {} } - ] } }, - hash: { type: :object, properties: {} }, - object: { type: :object, properties: {} } - }[type_name.try(:to_sym)] - end - - # Modify a JSON schema object by merging new properties into it or deleting a specified path. - # - # @param original_schema [Hash] The original schema object to modify. - # @param new_props [Hash] The new properties to merge into the schema. - # @param given_path [String, nil] The path to the property to modify or delete, if any. - # Use dot notation to specify nested properties (e.g. "person.address.city"). - # @param delete [Boolean] Whether to delete the property at the given path, if it exists. - # @raise [ArgumentError] If `delete` is true but `given_path` is nil, or if `given_path` does not exist in the original schema. - # - # @return [Hash] A new schema object with the specified modifications. - # - # @example Merge new properties into the schema - # original_schema = { type: 'object', properties: { name: { type: 'string' } } } - # new_props = { properties: { age: { type: 'integer' } } } - # modify_schema(original_schema, new_props) - # # => { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } } } - # - # @example Delete a property from the schema - # original_schema = { type: 'object', properties: { name: { type: 'string' } } } - # modify_schema(original_schema, {}, 'properties.name', delete: true) - # # => { type: 'object', properties: {} } - def modify_schema(original_schema, new_props, given_path = nil, delete: false) - return new_props if original_schema.nil? - - if given_path.nil? && delete - raise ArgumentError, "Cannot delete without a given path" - end - - if given_path.present? - path_segments = given_path.split('.').map(&:to_sym) - - if path_segments.size == 1 - unless original_schema.key?(path_segments.first) - raise ArgumentError, "Given path does not exist in the original schema" - end - else - unless original_schema.dig(*path_segments[0..-2]).is_a?(Hash) && original_schema.dig(*path_segments) - raise ArgumentError, "Given path does not exist in the original schema" - end - end - - path_hash = path_segments.reverse.reduce(new_props) { |a, n| { n => a } } - - if delete - new_schema = original_schema.deep_dup - parent_hash = path_segments.size > 1 ? new_schema.dig(*path_segments[0..-2]) : new_schema - parent_hash.delete(path_segments.last) - new_schema - else - original_schema.deep_merge(path_hash) - end - else - original_schema.deep_merge(new_props) - end - end - - # Returns a JSON Schema attribute definition for a given attribute on the model. - # - # @param attribute [Symbol] The name of the attribute. - # - # @raise [NoMethodError] If the `model` object does not respond to `columns_hash`. - # - # @return [Hash] The JSON Schema attribute definition as a Hash or an empty Hash if the attribute does not exist on the model. - # - # @example - # attribute_schema(:title) - # # => { "type": "string" } - def attribute_schema(attribute) - # Get the column hash for the attribute - column_hash = model.columns_hash[attribute.to_s] - - # Check if this attribute has a custom JSON Schema definition - if array_types.keys.include?(attribute) - return array_types[attribute] - end - - if additional_response_attributes.keys.include?(attribute) - return additional_response_attributes[attribute] - end - - # Check if this is an array attribute - if column_hash.as_json.try(:[], 'sql_type_metadata').try(:[], 'sql_type').include?('[]') - return type_mapper(:array) - end - - # Map the column type to a JSON Schema type if none of the above conditions are met - response = type_mapper(column_hash.try(:type)) - - # If the attribute is nullable, modify the schema accordingly - if response && nullable_attributes.include?(attribute) - return modify_schema(response, { nullable: true }) - end - - # If attribute is an enum, modify the schema accordingly - if response && model.defined_enums.key?(attribute.to_s) - return modify_schema(response, { type: :string, enum: model.defined_enums[attribute.to_s].keys }) - end - - return response unless response.nil? - - # If we haven't found a schema type yet, try to infer it from the type of the attribute's value in the instance data - type_from_factory = @instance.as_json['data']['attributes'][attribute.to_s.camelize(:lower)].class.name.downcase - response = type_mapper(type_from_factory) if type_from_factory.present? - - return response unless response.nil? - - # If we still haven't found a schema type, default to object - type_mapper(:object) - - rescue NoMethodError - # Log a warning if the attribute does not exist on the model - Rails.logger.warn("\e[33mWARNING: #{model} does not have an attribute named \e[31m#{attribute}\e[0m") - {} - end - - # Returns a JSON Schema for the model's attributes. - # This method is used to generate the schema for the `attributes` that are automatically generated by using the `attribute_schema` method on each attribute. - # - # @note The `additional_response_attributes` and `excluded_response_attributes` are applied to the schema returned by this method. - # - # @example - # { - # type: :object, - # properties: { - # id: { type: :string }, - # title: { type: :string } - # } - # } - # - # @return [Hash] The JSON Schema for the model's attributes. - def attributes_schema - schema = { - type: :object, - properties: attributes.reduce({}) do |props, attr| - props[attr] = attribute_schema(attr) - props - end - } - - # modify the schema to include additional response relations - schema = modify_schema(schema, additional_response_attributes, given_path = "properties") - - # modify the schema to exclude response relations - excluded_response_attributes.each do |key| - schema = modify_schema(schema, {}, "properties.#{key}", delete: true) - end - - schema - end - - # Generates the schema for the relationships of a resource. - # - # @param relations [Hash] A hash representing the relationships of the resource in the form of { belongs_to: {}, has_many: {} }. - # If not provided, the relationships will be inferred from the model's associations. - # - # @note The `additional_response_relations` and `excluded_response_relations` are applied to the schema returned by this method. - # - # @param expand [Boolean] A boolean indicating whether to expand the relationships in the schema. - # @param exclude_from_expansion [Array] An array of relationship names to exclude from expansion. - # - # @example - # { - # type: :object, - # properties: { - # province: { - # type: :object, - # properties: { - # meta: { - # type: :object, - # properties: { - # included: { - # type: :boolean, default: false - # } - # } - # } - # } - # } - # } - # } - # - # @return [Hash] A hash representing the schema for the relationships. - def relationships_schema(relations = try(:relationships), expand: false, exclude_from_expansion: []) - return {} if relations.blank? - return {} if relations == { belongs_to: {}, has_many: {} } - - schema = { - type: :object, - properties: relations.reduce({}) do |props, (relation_type, relation_definitions)| - non_expanded_data_properties = { - type: :object, - properties: { - meta: { - type: :object, - properties: { - included: { type: :boolean, default: false } - } - } - } - } - - if relation_type == :has_many - props.merge!( - relation_definitions.keys.index_with do |relationship| - - result = { - type: :object, - properties: { - data: { - type: :array, - items: { - type: :object, - properties: { - id: { type: :string }, - type: { type: :string, default: relation_definitions[relationship].model_name } - } - } - } - } - } - - result = non_expanded_data_properties if !expand || exclude_from_expansion.include?(relationship) - - result - end - ) - else - props.merge!( - relation_definitions.keys.index_with do |relationship| - - result = { - type: :object, - properties: { - data: { - type: :object, - properties: { - id: { type: :string }, - type: { type: :string, default: relation_definitions[relationship].model_name } - - } - } - } - } - - result = non_expanded_data_properties if !expand || exclude_from_expansion.include?(relationship) - - result - end - ) - end - end - } - - # modify the schema to include additional response relations - schema = modify_schema(schema, additional_response_relations, "properties") - - # modify the schema to exclude response relations - excluded_response_relations.each do |key| - schema = modify_schema(schema, {}, "properties.#{key}", delete: true) - end - - schema - end - - # Generates the schema for the included resources in a response. - # - # @note The `additional_response_includes` and `excluded_response_includes` (yet to be implemented) are applied to the schema returned by this method. - # - # @param relations [Hash] A hash representing the relationships of the resource in the form of { belongs_to: {}, has_many: {} }. - # If not provided, the relationships will be inferred from the model's associations. - # @param expand [Boolean] A boolean indicating whether to expand the relationships of the relationships in the schema. - # @param exclude_from_expansion [Array] An array of relationship names to exclude from expansion. - # @param metadata [Hash] Additional metadata to include in the schema, usually received from the nested_relationships method sent by the response_schema method. - # - # @example - # { - # included: { - # type: :array, - # items: { - # anyOf: - # [ - # { - # type: :object, - # properties: { - # type: { type: :string, default: "provinces" }, - # id: { type: :string }, - # attributes: { - # type: :object, - # properties: { - # id: { type: :string }, - # name: { type: :string } - # } - # } - # } - # } - # ] - # } - # } - # } - # - # @return [Hash] A hash representing the schema for the included resources. - def included_schema(relations = try(:relationships), expand: false, exclude_from_expansion: [], metadata: {}) - return {} if relations.blank? - return {} if relations == { belongs_to: {}, has_many: {} } - - schema = { - included: { - type: :array, - items: { - anyOf: - relations.reduce([]) do |props, (relation_type, relation_definitions)| - props + relation_definitions.keys.reduce([]) do |props, relationship| - props + [ - unless exclude_from_expansion.include?(relationship) - { - type: :object, - properties: { - type: { type: :string, default: relation_definitions[relationship].model_name }, - id: { type: :string }, - attributes: begin - relation_definitions[relationship].new.attributes_schema || {} - rescue NoMethodError - {} - end - }.merge( - if relation_definitions[relationship].new.relationships != { belongs_to: {}, has_many: {} } || relation_definitions[relationship].new.relationships.blank? - if !expand || metadata.blank? - { relationships: relation_definitions[relationship].new.relationships_schema(expand: false) } - else - { relationships: relation_definitions[relationship].new.relationships_schema(relations = metadata[:nested_relationships][relationship], expand: true, exclude_from_expansion: exclude_from_expansion) } - end - else - {} - end - ) - } - end - ].concat( - [ - if expand && metadata.present? && !exclude_from_expansion.include?(relationship) - extra_relations = [] - metadata[:nested_relationships].keys.reduce({}) do |props, nested_relationship| - if metadata[:nested_relationships][relationship].present? - props.merge!(metadata[:nested_relationships][nested_relationship].keys.each_with_object({}) do |relationship_type, inner_props| - props.merge!(metadata[:nested_relationships][nested_relationship][relationship_type].keys.each_with_object({}) do |relationship, inner_inner_props| - - extra_relation_schema = { - type: :object, - properties: { - type: { type: :string, default: metadata[:nested_relationships][nested_relationship][relationship_type][relationship].model_name }, - id: { type: :string }, - attributes: metadata[:nested_relationships][nested_relationship][relationship_type][relationship].new.attributes_schema - }.merge( - if metadata[:nested_relationships][nested_relationship][relationship_type][relationship].new.relationships == { belongs_to: {}, has_many: {} } || metadata[:nested_relationships][nested_relationship][relationship_type][relationship].new.relationships.blank? - {} - else - result = { relationships: metadata[:nested_relationships][nested_relationship][relationship_type][relationship].new.relationships_schema(expand: false) } - return {} if result == { relationships: {} } - result - end - ) - } + class << self + # Accessor for the module's configuration. + attr_accessor :configuration - extra_relations << extra_relation_schema - end - ) - end - ) - end - end - - extra_relations - end - ].flatten - ).compact_blank - end - end - } - } - } - - schema = modify_schema(schema, additional_response_included, "included.items") - - schema - end - - # Generates the schema for the response of a resource or collection of resources in JSON API format. - # - # @param relations [Hash] A hash representing the relationships of the resource in the form of { belongs_to: {}, has_many: {} }. - # If not provided, the relationships will be inferred from the model's associations. - # @param expand [Boolean] A boolean indicating whether to expand the relationships of the relationships in the schema. - # @param exclude_from_expansion [Array] An array of relationship names to exclude from expansion. - # @param multi [Boolean] A boolean indicating whether the response contains multiple resources. - # @param nested [Boolean] A boolean indicating whether the response is to be expanded further than the first level of relationships. (expand relationships of relationships) - # @param metadata [Hash] Additional metadata to include in the schema, usually received from the nested_relationships method sent by the response_schema method. - # - # @example - # The returned schema will have a JSON API format, including the data (included attributes and relationships), included and meta keys. - # - # @return [Hash] A hash representing the schema for the response. - def response_schema(relations = try(:relationships), expand: false, exclude_from_expansion: [], multi: false, nested: false, metadata: { nested_relationships: try(:nested_relationships) }) - - data = { - type: :object, - properties: { - type: { type: :string, default: itself.class.model_name }, - id: { type: :string }, - attributes: attributes_schema, - }.merge( - if relations.blank? || relations == { belongs_to: {}, has_many: {} } - {} - else - { relationships: relationships_schema(relations, expand: expand, exclude_from_expansion: exclude_from_expansion) } - end - ) - } - - schema = if multi - { - data: { - type: :array, - items: data - } - } - else - { - data: data - } - end - - schema.merge!( - if nested && expand - included_schema(relations, expand: nested, exclude_from_expansion: exclude_from_expansion, metadata: metadata) - elsif !nested && expand - included_schema(relations, expand: nested, exclude_from_expansion: exclude_from_expansion) - else - {} - end - ).merge!( - if !expand - { meta: meta } - else - {} - end - ).merge!( - jsonapi: jsonapi - ) - - { - type: :object, - properties: schema - } - end - - # Generates the schema for the request payload of a resource. - # - # @note The `additional_request_attributes` and `excluded_request_attributes` applied to the returned schema by this method. - # @note The `required_attributes` are applied to the returned schema by this method. - # @note The `nullable_attributes` are applied to the returned schema by this method. - # - # @example - # { - # type: :object, - # properties: { - # data: { - # type: :object, - # properties: { - # firstName: { type: :string }, - # lastName: { type: :string } - # }, - # required: [:firstName, :lastName] - # } - # } - # } - # - # @return [Hash] A hash representing the schema for the request payload. - def request_schema - schema = { - type: :object, - properties: { - data: attributes_schema - } - } - - schema = modify_schema(schema, additional_request_attributes, "properties.data.properties") - - excluded_request_attributes.each do |key| - schema = modify_schema(schema, {}, "properties.data.properties.#{key}", delete: true) - end - - required_attributes = { - required: schema.as_json['properties']['data']['properties'].keys - optional_request_attributes.map(&:to_s) - nullable_attributes.map(&:to_s) - } - - schema = modify_schema(schema, required_attributes, "properties.data") - - schema - end - - # Returns the schema for the meta data of the response body. - # - # This is used to provide pagination information usually (in the case of a collection). - # - # Note that this is an opinionated schema and may not be suitable for all use cases. - # If you need to override this schema, you can do so by overriding the `meta` method in your definition. - # - # @return [Hash] The schema for the meta data of the response body. - def meta - { - type: :object, - properties: { - page: { - type: :object, - properties: { - totalPages: { - type: :integer, - default: 1 - }, - count: { - type: :integer, - default: 1 - }, - limitValue: { - type: :integer, - default: 1 - }, - currentPage: { - type: :integer, - default: 1 - } - } - } - } - } - end - - # Returns the schema for the JSONAPI version. - # - # @return [Hash] The schema for the JSONAPI version. - def jsonapi - { - type: :object, - properties: { - version: { - type: :string, - default: "1.0" - } - } - } - end - - # Returns the resource serializer to be used for serialization. This method must be implemented in the definition class. - # - # @raise [NotImplementedError] If the method is not implemented in the definition class. - # - # @example V1::UserSerializer - # - # @abstract This method must be implemented in the definition class. - # - # @return [Class] The resource serializer class. - def serializer - raise NotImplementedError, 'serializer method must be implemented in the definition class' - end - - # Returns the attributes defined in the serializer (Auto generated from the serializer). - # - # @example - # [:id, :name, :email, :created_at, :updated_at] - # - # @return [Array, nil] The attributes defined in the serializer or nil if there are none. - def attributes - serializer.attribute_blocks.transform_keys { |key| key.to_s.underscore.to_sym }.keys || nil - end - - # Returns the relationships defined in the serializer. - # - # Note that the format of the relationships is as follows: { belongs_to: { relationship_name: relationship_definition }, has_many: { relationship_name: relationship_definition } - # - # @example - # { - # belongs_to: { - # district: Swagger::Definitions::District, - # user: Swagger::Definitions::User - # }, - # has_many: { - # applicants: Swagger::Definitions::Applicant, - # } - # } - # - # @return [Hash] The relationships defined in the serializer. - def relationships - { belongs_to: {}, has_many: {} } - end - - # Returns a hash of all the arrays defined for the model. The schema for each array is defined in the definition class manually. - # - # This method must be implemented in the definition class if there are any arrays. - # - # @example - # { - # metadata: { - # type: :array, - # items: { - # type: :object, nullable: true, - # properties: { name: { type: :string, nullable: true } } - # } - # } - # } - # - # @return [Hash] The arrays of the model and their schemas. - def array_types - {} - end - - # Returns the attributes that are optional in the request body. This means that they are not required to be present in the request body thus they are taken out of the required array. - # - # @example - # [:name, :email] - # - # @return [Array] The attributes that are optional in the request body. - def optional_request_attributes - %i[] - end - - # Returns the attributes that are nullable in the request/response body. This means that they can be present in the request/response body but they can be null. - # - # They are not required to be present in the request body. - # - # @example - # [:name, :email] - # - # @return [Array] The attributes that are nullable in the request/response body. - def nullable_attributes - %i[] - end - - # Returns the additional request attributes that are not automatically generated. These attributes are appended to the request schema. - # - # @example - # { - # name: { type: :string } - # } - # - # @return [Hash] The additional request attributes that are not automatically generated (if any). - def additional_request_attributes - {} - end - - # Returns the additional response attributes that are not automatically generated. These attributes are appended to the response schema. - # - # @example - # { - # name: { type: :string } - # } - # - # @return [Hash] The additional response attributes that are not automatically generated (if any). - def additional_response_attributes - {} - end - - # Returns the additional response relations that are not automatically generated. These relations are appended to the response schema's relationships. - # - # @example - # { - # users: { - # type: :object, - # properties: { - # data: { - # type: :array, - # items: { - # type: :object, - # properties: { - # id: { type: :string }, - # type: { type: :string } - # } - # } - # } - # } - # } - # } - # - # @return [Hash] The additional response relations that are not automatically generated (if any). - def additional_response_relations - {} - end - - # Returns the additional response included that are not automatically generated. These included are appended to the response schema's included. - # - # @example - # { - # type: :object, - # properties: { - # id: { type: :string }, - # type: { type: :string }, - # attributes: { - # type: :object, - # properties: { - # name: { type: :string } - # } - # } - # } - # } - # - # @return [Hash] The additional response included that are not automatically generated (if any). - def additional_response_included - {} - end - - # Returns the attributes that are excluded from the request schema. - # These attributes are not required or not needed to be present in the request body. - # - # @example - # [:id, :updated_at, :created_at] - # - # @return [Array] The attributes that are excluded from the request schema. - def excluded_request_attributes - %i[] - end - - # Returns the attributes that are excluded from the response schema. - # These attributes are not needed to be present in the response body. - # - # @example - # [:id, :updated_at, :created_at] - # - # @return [Array] The attributes that are excluded from the response schema. - def excluded_response_attributes - %i[] - end - - # Returns the relationships that are excluded from the response schema. - # These relationships are not needed to be present in the response body. - # - # @example - # [:users, :applicants] - # - # @return [Array] The relationships that are excluded from the response schema. - def excluded_response_relations - %i[] - end - - # Returns the included that are excluded from the response schema. - # These included are not needed to be present in the response body. - # - # @todo This method is not used anywhere yet. - # - # @example - # [:users, :applicants] - # - # @return [Array] The included that are excluded from the response schema. - def excluded_response_included - %i[] - end - - # Returns the relationships to be further expanded in the response schema. - # - # @example - # { - # applicants: { - # belongs_to: { - # district: Swagger::Definitions::District, - # province: Swagger::Definitions::Province, - # }, - # has_many: { - # attachments: Swagger::Definitions::Upload, - # } - # } - # } - # - # @return [Hash] The relationships to be further expanded in the response schema. - def nested_relationships - {} - end - - # Returns the model class (Constantized from the definition class name) - # - # @example - # User - # - # @return [Class] The model class (Constantized from the definition class name) - def model - self.class.name.gsub("Swagger::Definitions::", '').constantize - end - - # Returns the model name. Used for schema type naming. - # - # @example - # 'users' for the User model - # 'citizen_applications' for the CitizenApplication model - # - # @return [String] The model name. - def self.model_name - name.gsub("Swagger::Definitions::", '').pluralize.underscore.downcase - end - - # Returns the generated schemas in JSONAPI format that are used in the swagger documentation. - # - # @note This method is used for generating schema in 3 different formats: request, response and response expanded. - # request: The schema for the request body. - # response: The schema for the response body (without any relationships expanded), used for collection responses. - # response expanded: The schema for the response body with all the relationships expanded, used for single resource responses. - # - # @note The returned schemas are in JSONAPI format are usually appended to the rswag component's 'schemas' in swagger_helper. - # - # @note The method can be overridden in the definition class if there are any additional customizations needed. - # - # @return [Array] The generated schemas in JSONAPI format that are used in the swagger documentation. - def self.definitions - schema_instance = self.new - [ - "#{schema_instance.model}Request": schema_instance.camelize_keys(schema_instance.request_schema), - "#{schema_instance.model}Response": schema_instance.camelize_keys(schema_instance.response_schema(multi: true)), - "#{schema_instance.model}ResponseExpanded": schema_instance.camelize_keys(schema_instance.response_schema(expand: true)) - ] - end - - # Given a hash, it returns a new hash with all the keys camelized. - # - # @param hash [Array | Hash] The hash with all the keys camelized. - # - # @example - # { first_name: 'John', last_name: 'Doe' } => { firstName: 'John', lastName: 'Doe' } + # Configures the module. If a block is given, it yields the current configuration. # - # @return [Array | Hash] The hash with all the keys camelized. - def camelize_keys(hash) - hash.deep_transform_keys { |key| key.to_s.camelize(:lower).to_sym } + # @yield [Configuration] The current configuration. + def configure + @configuration ||= Configuration.new + yield(@configuration) if block_given? end end end diff --git a/lib/schemable/attribute_schema_generator.rb b/lib/schemable/attribute_schema_generator.rb new file mode 100644 index 0000000..1722613 --- /dev/null +++ b/lib/schemable/attribute_schema_generator.rb @@ -0,0 +1,146 @@ +module Schemable + # The AttributeSchemaGenerator class is responsible for generating JSON schemas for model attributes. + # It includes methods for generating the overall schema and individual attribute schemas. + # + # @see Schemable + class AttributeSchemaGenerator + attr_reader :model_definition, :configuration, :model, :schema_modifier, :response + + # Initializes a new AttributeSchemaGenerator instance. + # + # @param model_definition [ModelDefinition] The model definition to generate the schema for. + # @example + # generator = AttributeSchemaGenerator.new(model_definition) + def initialize(model_definition) + @model_definition = model_definition + @model = model_definition.model + @configuration = Schemable.configuration + @schema_modifier = SchemaModifier.new + @response = nil + end + + # Generates the JSON schema for the model attributes. + # + # @return [Hash] The generated schema. + # @example + # schema = generator.generate + def generate + schema = { + type: :object, + properties: @model_definition.attributes.index_with do |attr| + generate_attribute_schema(attr) + end + } + + # Rename enum attributes to remove the suffix or prefix if mongoid is used + if @configuration.orm == :mongoid + schema[:properties].transform_keys! do |key| + key.to_s.gsub(@configuration.enum_prefix_for_simple_enum || @configuration.enum_suffix_for_simple_enum, '') + end + end + + # modify the schema to include additional response relations + schema = @schema_modifier.add_properties(schema, @model_definition.additional_response_attributes, 'properties') + + # modify the schema to exclude response relations + @model_definition.excluded_response_attributes.each do |key| + schema = @schema_modifier.delete_properties(schema, "properties.#{key}") + end + + schema + end + + # Generates the JSON schema for a specific attribute. + # + # @param attribute [Symbol, String] The attribute to generate the schema for. + # @return [Hash] The generated schema for the attribute. + # @example + # attribute_schema = generator.generate_attribute_schema(:attribute_name) + def generate_attribute_schema(attribute) + if @configuration.orm == :mongoid + # Get the column hash for the attribute + attribute_hash = @model.fields[attribute.to_s] + + # Check if this attribute has a custom JSON Schema definition + return @model_definition.array_types[attribute] if @model_definition.array_types.keys.include?(attribute.to_sym) + return @model_definition.additional_response_attributes[attribute] if @model_definition.additional_response_attributes.keys.include?(attribute) + + # Check if this is an array attribute + return @configuration.type_mapper(:array) if attribute_hash.try(:[], 'options').try(:[], 'type') == 'Array' + + # Check if this is an enum attribute + @response = if attribute_hash.name.end_with?('_cd') + @configuration.type_mapper(:string) + else + # Map the column type to a JSON Schema type if none of the above conditions are met + @configuration.type_mapper(attribute_hash.try(:type).to_s.downcase.to_sym) + end + + elsif @configuration.orm == :active_record + # Get the column hash for the attribute + attribute_hash = @model.columns_hash[attribute.to_s] + + # Check if this attribute has a custom JSON Schema definition + return @model_definition.array_types[attribute] if @model_definition.array_types.keys.include?(attribute.to_sym) + return @model_definition.additional_response_attributes[attribute] if @model_definition.additional_response_attributes.keys.include?(attribute) + + # Check if this is an array attribute + return @configuration.type_mapper(:array) if attribute_hash.as_json.try(:[], 'sql_type_metadata').try(:[], 'sql_type').include?('[]') + + # Map the column type to a JSON Schema type if none of the above conditions are met + @response = @configuration.type_mapper(attribute_hash.try(:type)) + + else + raise 'ORM not supported' + end + + # If the attribute is nullable, modify the schema accordingly + return @schema_modifier.add_properties(@response, { nullable: true }, '.') if @response && @model_definition.nullable_attributes.include?(attribute) + + # If attribute is an enum, modify the schema accordingly + if @configuration.custom_defined_enum_method && @model.respond_to?(@configuration.custom_defined_enum_method) + defined_enums = @model.send(@configuration.custom_defined_enum_method) + enum_attribute = attribute.to_s.gsub(@configuration.enum_prefix_for_simple_enum || @configuration.enum_suffix_for_simple_enum, '').to_s + if @response && defined_enums[enum_attribute].present? + return @schema_modifier.add_properties( + @response, + { + enum: defined_enums[enum_attribute].keys, + default: @model_definition.default_value_for_enum_attributes[attribute.to_sym] || defined_enums[enum_attribute].keys.first + }, + '.' + ) + end + elsif @model.respond_to?(:defined_enums) && @response && @model.defined_enums.key?(attribute.to_s) + return @schema_modifier.add_properties( + @response, + { + enum: @model.defined_enums[attribute.to_s].keys, + default: @model_definition.default_value_for_enum_attributes[attribute.to_sym] || @model.defined_enums[attribute.to_s].keys.first + }, + '.' + ) + end + + return @response unless @response.nil? + + # If we haven't found a schema type yet, try to infer it from the type of the attribute's value in the instance data + if @configuration.use_serialized_instance + serialized_instance = @model_definition.serialized_instance + + type_from_instance = serialized_instance.as_json['data']['attributes'][attribute.to_s.camelize(:lower)]&.class&.name&.downcase + + @response = @configuration.type_mapper(type_from_instance) if type_from_instance.present? + + return @response unless @response.nil? + end + + # If we still haven't found a schema type, default to object + @configuration.type_mapper(:object) + rescue NoMethodError + # Log a warning if the attribute does not exist on the @model + Rails.logger.warn("\e[33mWARNING: #{@model} does not have an attribute named \e[31m#{attribute}\e[0m") + {} + end + end +end diff --git a/lib/schemable/configuration.rb b/lib/schemable/configuration.rb new file mode 100644 index 0000000..fbe45f9 --- /dev/null +++ b/lib/schemable/configuration.rb @@ -0,0 +1,116 @@ +module Schemable + # The Configuration class provides a set of configuration options for the Schemable module. + # It includes options for setting the ORM, handling enums, custom type mappers, and more. + # It is worth noting that the configuration options are global, and will affect all Definitions. + # + # @see Schemable + class Configuration + attr_accessor( + :orm, + :float_as_string, + :decimal_as_string, + :pagination_enabled, + :custom_type_mappers, + :use_serialized_instance, + :custom_defined_enum_method, + :enum_prefix_for_simple_enum, + :enum_suffix_for_simple_enum, + :custom_meta_response_schema, + :infer_expand_nested_from_expand, + :infer_attributes_from_custom_method, + :infer_attributes_from_jsonapi_serializable + ) + + # Initializes a new Configuration instance with default values. + def initialize + @orm = :active_record # orm options are :active_record, :mongoid + @float_as_string = false + @custom_type_mappers = {} + @pagination_enabled = true + @decimal_as_string = false + @use_serialized_instance = false + @custom_defined_enum_method = nil + @custom_meta_response_schema = nil + @enum_prefix_for_simple_enum = nil + @enum_suffix_for_simple_enum = nil + @infer_expand_nested_from_expand = false + @infer_attributes_from_custom_method = nil + @infer_attributes_from_jsonapi_serializable = false + end + + # Returns a type mapper for a given type name. + # + # @note If a custom type mapper is defined for the given type name, it will be returned. + # + # @example + # type_mapper(:string) #=> { type: :string } + # + # @param type_name [Symbol, String] The name of the type. + # @return [Hash] The type mapper for the given type name. + def type_mapper(type_name) + return @custom_type_mappers[type_name] if @custom_type_mappers.key?(type_name.to_sym) + + { + text: { type: :string }, + string: { type: :string }, + symbol: { type: :string }, + integer: { type: :integer }, + boolean: { type: :boolean }, + date: { type: :string, format: :date }, + time: { type: :string, format: :time }, + json: { type: :object, properties: {} }, + hash: { type: :object, properties: {} }, + jsonb: { type: :object, properties: {} }, + object: { type: :object, properties: {} }, + binary: { type: :string, format: :binary }, + trueclass: { type: :boolean, default: true }, + falseclass: { type: :boolean, default: false }, + datetime: { type: :string, format: :'date-time' }, + big_decimal: { type: (@decimal_as_string ? :string : :number).to_s.to_sym, format: :double }, + 'bson/objectid': { type: :string, format: :object_id }, + 'mongoid/boolean': { type: :boolean }, + 'mongoid/stringified_symbol': { type: :string }, + 'active_support/time_with_zone': { type: :string, format: :date_time }, + float: { + type: (@float_as_string ? :string : :number).to_s.to_sym, + format: :float + }, + decimal: { + type: (@decimal_as_string ? :string : :number).to_s.to_sym, + format: :double + }, + array: { + type: :array, + items: { + anyOf: [ + { type: :string }, + { type: :integer }, + { type: :boolean }, + { type: :number, format: :float }, + { type: :object, properties: {} }, + { type: :number, format: :double } + ] + } + } + }[type_name.to_s.underscore.try(:to_sym)] + end + + # Adds a custom type mapper for a given type name. + # + # @example + # add_custom_type_mapper(:custom_type, { type: :custom }) + # type_mapper(:custom_type) #=> { type: :custom } + # + # # It preferable to invoke this method in the config/initializers/schemable.rb file. + # # This way, the custom type mapper will be available for all Definitions. + # Schemable.configure do |config| + # config.add_custom_type_mapper(:custom_type, { type: :custom }) + # end + # + # @param type_name [Symbol, String] The name of the type. + # @param mapping [Hash] The mapping to add. + def add_custom_type_mapper(type_name, mapping) + custom_type_mappers[type_name.to_sym] = mapping + end + end +end diff --git a/lib/schemable/definition.rb b/lib/schemable/definition.rb new file mode 100644 index 0000000..f25d1b1 --- /dev/null +++ b/lib/schemable/definition.rb @@ -0,0 +1,347 @@ +module Schemable + # The Definition class provides a blueprint for generating and modifying schemas. + # It includes methods for handling attributes, relationships, and various request and response attributes. + # The definition class is meant to be inherited by a class that represents a model. + # This class should be configured to match the model's attributes and relationships. + # The default configuration is set in this class, but can be overridden in the model's definition class. + # + # @see Schemable + class Definition + attr_reader :configuration + attr_writer :relationships, :additional_create_request_attributes, :additional_update_request_attributes + + def initialize + @configuration = Schemable.configuration + end + + # Returns the serializer of the model for the definition. + # @example + # UsersSerializer + # @return [JSONAPI::Serializable::Resource, nil] The model's serializer. + def serializer + raise NotImplementedError, 'You must implement the serializer method in the definition class in order to use the infer_serializer_from_jsonapi_serializable configuration option.' if configuration.infer_attributes_from_jsonapi_serializable + + nil + end + + # Returns the attributes for the definition based on the configuration. + # The attributes are inferred from the model's attribute names by default. + # If the infer_attributes_from_custom_method configuration option is set, the attributes are inferred from the method specified. + # If the infer_attributes_from_jsonapi_serializable configuration option is set, the attributes are inferred from the serializer's attribute blocks. + # + # @example + # attributes = definition.attributes # => [:id, :name, :email] + # + # @return [Array] The attributes used for generating the schemas. + def attributes + return (serializer&.attribute_blocks&.transform_keys { |key| key.to_s.underscore.to_sym }&.keys || nil) if configuration.infer_attributes_from_jsonapi_serializable + + return model.send(configuration.infer_attributes_from_custom_method).map(&:to_sym) if configuration.infer_attributes_from_custom_method + + model.attribute_names.map(&:to_sym) + end + + # Returns the relationships defined in the serializer. + # + # @note Note that the format of the relationships is as follows: + # { + # belongs_to: { relationship_name: relationship_definition }, + # has_many: { relationship_name: relationship_definition }, + # addition_to_included: { relationship_name: relationship_definition } + # } + # + # @note The addition_to_included is used to define the extra nested relationships that are not defined in the belongs_to or has_many for included. + # + # @example + # { + # belongs_to: { + # district: Swagger::Definitions::District, + # user: Swagger::Definitions::User + # }, + # has_many: { + # applicants: Swagger::Definitions::Applicant, + # }, + # addition_to_included: { + # applicants: Swagger::Definitions::Applicant + # } + # } + # + # @return [Hash] The relationships defined in the serializer. + def relationships + { belongs_to: {}, has_many: {} } + end + + # Returns a hash of all the arrays defined for the model. + # The schema for each array is defined in the definition class manually. + # This method must be implemented in the definition class if there are any arrays. + # + # @return [Hash] The arrays of the model and their schemas. + # + # @example + # { + # metadata: { + # type: :array, + # items: { + # type: :object, nullable: true, + # properties: { name: { type: :string, nullable: true } } + # } + # } + # } + def array_types + {} + end + + # Attributes that are not required in the create request. + # + # @example + # optional_create_request_attributes = definition.optional_create_request_attributes + # # => [:email] + # + # @return [Array] The attributes that are not required in the create request. + def optional_create_request_attributes + %i[] + end + + # Attributes that are not required in the update request. + # + # @example + # optional_update_request_attributes = definition.optional_update_request_attributes + # # => [:email] + # + # @return [Array] The attributes that are not required in the update request. + def optional_update_request_attributes + %i[] + end + + # Returns the attributes that are nullable in the request/response body. + # This means that they can be present in the request/response body but they can be null. + # They are not required to be present in the request body. + # + # @example + # [:name, :email] + # + # @return [Array] The attributes that are nullable in the request/response body. + def nullable_attributes + %i[] + end + + # Returns the relationships that are nullable in the response body. + # This means that they can be present in the response body but they can be null. + # They are not required to be present in the request body. + # + # @example + # ['users', 'applicant'] + # + # @return [Array] The attributes that are nullable in the response body. + def nullable_relationships + %w[] + end + + # Returns the additional create request attributes that are not automatically generated. + # These attributes are appended to the create request schema. + # + # @example + # { name: { type: :string } } + # + # @return [Hash] The additional create request attributes that are not automatically generated (if any). + def additional_create_request_attributes + {} + end + + # Returns the additional update request attributes that are not automatically generated. + # These attributes are appended to the update request schema. + # + # @example + # { name: { type: :string } } + # + # @return [Hash] The additional update request attributes that are not automatically generated (if any). + def additional_update_request_attributes + {} + end + + # Returns the additional response attributes that are not automatically generated. These attributes are appended to the response schema. + # + # @example + # { name: { type: :string } } + # + # @return [Hash] The additional response attributes that are not automatically generated (if any). + def additional_response_attributes + {} + end + + # Returns the additional response relations that are not automatically generated. + # These relations are appended to the response schema's relationships. + # + # @example + # { + # users: { + # type: :object, + # properties: { + # data: { + # type: :array, + # items: { + # type: :object, + # properties: { + # id: { type: :string }, + # type: { type: :string } + # } + # } + # } + # } + # } + # } + # + # @return [Hash] The additional response relations that are not automatically generated (if any). + def additional_response_relations + {} + end + + # Returns the additional response included that are not automatically generated. + # These included additions are appended to the response schema's included. + # + # @example + # { + # type: :object, + # properties: { + # id: { type: :string }, + # type: { type: :string }, + # attributes: { + # type: :object, + # properties: { + # name: { type: :string } + # } + # } + # } + # } + # + # @return [Hash] The additional response included that are not automatically generated (if any). + def additional_response_included + {} + end + + # Returns the attributes that are excluded from the create request schema. + # These attributes are not required or not needed to be present in the create request body. + # + # @example + # [:id, :updated_at, :created_at] + # + # @return [Array] The attributes that are excluded from the create request schema. + def excluded_create_request_attributes + %i[] + end + + # Returns the attributes that are excluded from the response schema. + # These attributes are not needed to be present in the response body. + # + # @example + # [:id, :updated_at, :created_at] + # + # @return [Array] The attributes that are excluded from the response schema. + def excluded_update_request_attributes + %i[] + end + + # Returns the attributes that are excluded from the update request schema. + # These attributes are not required or not needed to be present in the update request body. + # + # @example + # [:id, :updated_at, :created_at] + # + # @return [Array] The attributes that are excluded from the update request schema. + def excluded_response_attributes + %i[] + end + + # Returns the relationships that are excluded from the response schema. + # These relationships are not needed to be present in the response body. + # + # @example + # [:users, :applicants] + # + # @return [Array] The relationships that are excluded from the response schema. + def excluded_response_relations + %i[] + end + + # Returns the included that are excluded from the response schema. + # These included are not needed to be present in the response body. + # + # @example + # [:users, :applicants] + # + # @todo + # This method is not used anywhere yet. + # + # @return [Array] The included that are excluded from the response schema. + def excluded_response_included + %i[] + end + + # Returns the default value for the enum attributes. + # + # @example + # { + # status: 'pending', + # flag: 0 + # } + # + # @return [Hash] The custom default values for the enum attributes. + def default_value_for_enum_attributes + {} + end + + # Returns an instance of the model class that is already serialized into jsonapi format. + # + # @return [Hash] The serialized instance of the model class. + def serialized_instance + {} + end + + # Returns the model class (Constantized from the definition class name) + # + # @example + # User + # + # @return [Class] The model class (Constantized from the definition class name) + def model + self.class.name.gsub('Swagger::Definitions::', '').constantize + end + + # Returns the model name. Used for schema type naming. + # + # @return [String] The model name. + # + # @example + # 'users' for the User model + # 'citizen_applications' for the CitizenApplication model + def model_name + self.class.name.gsub('Swagger::Definitions::', '').pluralize.underscore.downcase + end + + # Given a hash, it returns a new hash with all the keys camelized. + # + # @param hash [Hash] The hash with all the keys camelized. + # + # @return [Hash, Array] The hash with all the keys camelized. + # + # @example + # { first_name: 'John', last_name: 'Doe' } => { firstName: 'John', lastName: 'Doe' } + def camelize_keys(hash) + hash.deep_transform_keys { |key| key.to_s.camelize(:lower).to_sym } + end + + # Returns the schema for the create request body, update request body, and response body. + # + # @return [Array] The schema for the create request body, update request body, and response body. + def self.generate + instance = new + + [ + "#{instance.model}CreateRequest": instance.camelize_keys(RequestSchemaGenerator.new(instance).generate_for_create), + "#{instance.model}UpdateRequest": instance.camelize_keys(RequestSchemaGenerator.new(instance).generate_for_update), + "#{instance.model}Response": instance.camelize_keys(ResponseSchemaGenerator.new(instance).generate(collection: true)), + "#{instance.model}ResponseExpanded": instance.camelize_keys(ResponseSchemaGenerator.new(instance).generate(expand: true)) + ] + end + end +end diff --git a/lib/schemable/included_schema_generator.rb b/lib/schemable/included_schema_generator.rb new file mode 100644 index 0000000..f326839 --- /dev/null +++ b/lib/schemable/included_schema_generator.rb @@ -0,0 +1,107 @@ +module Schemable + # The IncludedSchemaGenerator class is responsible for generating the 'included' part of a JSON:API compliant response. + # This class generates schemas for related resources that should be included in the response. + # + # @see Schemable + class IncludedSchemaGenerator + attr_reader :model_definition, :schema_modifier, :relationships + + # Initializes a new IncludedSchemaGenerator instance. + # + # @param model_definition [ModelDefinition] The model definition to generate the schema for. + # + # @example + # generator = IncludedSchemaGenerator.new(model_definition) + def initialize(model_definition) + @model_definition = model_definition + @schema_modifier = SchemaModifier.new + @relationships = @model_definition.relationships + end + + # Generates the 'included' part of the JSON:API response. + # It iterates over each relationship type (belongs_to, has_many) and for each relationship, + # it prepares a schema. If the 'expand' option is true, it also includes the relationships of the related resource in the schema. + # In that case, the 'addition_to_included' relationships are also included in the schema unless they are excluded from expansion. + # + # @param expand [Boolean] Whether to include the relationships of the related resource in the schema. + # @param relationships_to_exclude_from_expansion [Array] The relationships to exclude from the schema. + # + # @note Make sure to provide the names correctly in string format and pluralize them if necessary. + # For example, if you have a relationship named 'applicant', and an applicant has association + # named 'identity', you should provide 'identities' as the names of the relationship to exclude from expansion. + # In this case, the included schema of the applicant will not include the identity relationship. + # + # @example + # schema = generator.generate(expand: true, relationships_to_exclude_from_expansion: ['some_relationship']) + # + # @return [Hash] The generated schema. + def generate(expand: false, relationships_to_exclude_from_expansion: []) + return {} if @relationships.blank? + return {} if @relationships == { belongs_to: {}, has_many: {} } + + definitions = [] + + %i[belongs_to has_many addition_to_included].each do |relation_type| + next if @relationships[relation_type].blank? + + definitions << @relationships[relation_type].values + end + + definitions.flatten! + + included_schemas = definitions.map do |definition| + next if relationships_to_exclude_from_expansion.include?(definition.model_name) + + if expand + definition_relations = definition.relationships[:belongs_to].values.map(&:model_name) + definition.relationships[:has_many].values.map(&:model_name) + relations_to_exclude = [] + definition_relations.each do |relation| + relations_to_exclude << relation if relationships_to_exclude_from_expansion.include?(relation) + end + + prepare_schema_for_included(definition, expand:, relationships_to_exclude_from_expansion: relations_to_exclude) + else + prepare_schema_for_included(definition) + end + end + + schema = { + included: { + type: :array, + items: { + anyOf: included_schemas.compact_blank + } + } + } + + @schema_modifier.add_properties(schema, @model_definition.additional_response_included, 'included.items') if @model_definition.additional_response_included.present? + + schema + end + + # Prepares the schema for a related resource to be included in the response. + # It generates the attribute and relationship schemas for the related resource and combines them into a single schema. + # + # @param model_definition [ModelDefinition] The model definition of the related resource. + # @param expand [Boolean] Whether to include the relationships of the related resource in the schema. + # @param relationships_to_exclude_from_expansion [Array] The relationships to exclude from the schema. + # + # @example + # included_schema = generator.prepare_schema_for_included(related_model_definition, expand: true, relationships_to_exclude_from_expansion: ['some_relationship']) + # + # @return [Hash] The generated schema for the related resource. + def prepare_schema_for_included(model_definition, expand: false, relationships_to_exclude_from_expansion: []) + attributes_schema = AttributeSchemaGenerator.new(model_definition).generate + relationships_schema = RelationshipSchemaGenerator.new(model_definition).generate(relationships_to_exclude_from_expansion:, expand:) + + { + type: :object, + properties: { + type: { type: :string, default: model_definition.model_name }, + id: { type: :string }, + attributes: attributes_schema + }.merge!(relationships_schema.blank? ? {} : { relationships: relationships_schema }) + }.compact_blank + end + end +end diff --git a/lib/schemable/relationship_schema_generator.rb b/lib/schemable/relationship_schema_generator.rb new file mode 100644 index 0000000..fa5861a --- /dev/null +++ b/lib/schemable/relationship_schema_generator.rb @@ -0,0 +1,126 @@ +module Schemable + # The RelationshipSchemaGenerator class is responsible for generating the 'relationships' part of a JSON:API compliant response. + # This class generates schemas for each relationship of a model, including 'belongs_to' (and has_many) and 'has_many' relationships. + # + # @see Schemable + class RelationshipSchemaGenerator + attr_reader :model_definition, :schema_modifier, :relationships + + # Initializes a new RelationshipSchemaGenerator instance. + # + # @param model_definition [ModelDefinition] The model definition to generate the schema for. + # + # @example + # generator = RelationshipSchemaGenerator.new(model_definition) + def initialize(model_definition) + @model_definition = model_definition + @schema_modifier = SchemaModifier.new + @relationships = model_definition.relationships + end + + # Generates the 'relationships' part of the JSON:API response. + # It iterates over each relationship type (belongs_to, has_many) and for each relationship, + # it prepares a schema unless the relationship is excluded from expansion. + # If the 'expand' option is true, it changes the schema to include type and id properties inside the 'meta' property. + # + # @param relationships_to_exclude_from_expansion [Array] The relationships to exclude from expansion. + # @param expand [Boolean] Whether to include the relationships of the related resource in the schema. + # + # @example + # schema = generator.generate(expand: true, relationships_to_exclude_from_expansion: [:some_relationship]) + # + # @return [Hash] The generated schema. + def generate(relationships_to_exclude_from_expansion: [], expand: false) + return {} if @relationships.blank? || @relationships == { belongs_to: {}, has_many: {} } + + schema = { + type: :object, + properties: {} + } + + %i[belongs_to has_many].each do |relation_type| + @relationships[relation_type]&.each do |relation, definition| + non_expanded_data_properties = { + type: :object, + properties: { + meta: { + type: :object, + properties: { + included: { type: :boolean, default: false } + } + } + } + } + + result = relation_type == :belongs_to ? generate_schema(definition.model_name) : generate_schema(definition.model_name, collection: true) + + result = non_expanded_data_properties if !expand || relationships_to_exclude_from_expansion.include?(definition.model_name) + + schema[:properties].merge!(relation => result) + end + end + + # Modify the schema to include additional response relations + schema = @schema_modifier.add_properties(schema, @model_definition.additional_response_relations, 'properties') + + # Modify the schema to exclude response relations + @model_definition.excluded_response_relations.each do |key| + schema = @schema_modifier.delete_properties(schema, "properties.#{key}") + end + + schema + end + + # Generates the schema for a specific relationship. + # If the 'collection' option is true, it generates a schema for a 'has_many' relationship, + # otherwise it generates a schema for a 'belongs_to' relationship. The difference between the two is that + # 'data' is an array in the 'has_many' relationship and an object in the 'belongs_to' relationship. + # + # @param type_name [String] The type of the related resource. + # @param collection [Boolean] Whether the relationship is a 'has_many' relationship. + # + # @example + # relationship_schema = generator.generate_schema('resource_type', collection: true) + # + # @return [Hash] The generated schema for the relationship. + def generate_schema(type_name, collection: false) + schema = if collection + { + type: :object, + properties: { + data: { + type: :array, + items: { + type: :object, + properties: { + id: { type: :string }, + type: { type: :string, default: type_name } + } + } + } + } + } + else + { + type: :object, + properties: { + data: { + type: :object, + properties: { + id: { type: :string }, + type: { type: :string, default: type_name } + } + } + } + } + end + + # Modify the schema to nullable if the relationship is in nullable + is_relation_nullable = @model_definition.nullable_relationships.include?(type_name) + + return schema unless is_relation_nullable + + @schema_modifier.add_properties(schema, { nullable: true }, 'properties.data') + end + end +end diff --git a/lib/schemable/request_schema_generator.rb b/lib/schemable/request_schema_generator.rb new file mode 100644 index 0000000..c66761a --- /dev/null +++ b/lib/schemable/request_schema_generator.rb @@ -0,0 +1,88 @@ +module Schemable + # The RequestSchemaGenerator class is responsible for generating JSON schemas for create and update requests. + # This class generates schemas based on the model definition, including additional and excluded attributes. + # + # @see Schemable + class RequestSchemaGenerator + attr_reader :model_definition, :schema_modifier + + # Initializes a new RequestSchemaGenerator instance. + # + # @param model_definition [ModelDefinition] The model definition to generate the schema for. + # + # @example + # generator = RequestSchemaGenerator.new(model_definition) + def initialize(model_definition) + @model_definition = model_definition + @schema_modifier = SchemaModifier.new + end + + # Generates the JSON schema for a create request. + # It generates a schema for the model attributes and then modifies it based on the additional and excluded attributes for create requests. + # It also determines the required attributes based on the optional and nullable attributes. + # Note that it is presumed that the model is using the same fields/columns for create as well as responses. + # + # @example + # schema = generator.generate_for_create + # + # @return [Hash] The generated schema for create requests. + def generate_for_create + schema = { + type: :object, + properties: { + data: AttributeSchemaGenerator.new(@model_definition).generate + } + } + + @schema_modifier.add_properties(schema, @model_definition.additional_create_request_attributes, 'properties.data.properties') + + @model_definition.excluded_create_request_attributes.each do |key| + @schema_modifier.delete_properties(schema, "properties.data.properties.#{key}") + end + + required_attributes = { + required: ( + schema.as_json['properties']['data']['properties'].keys - + @model_definition.optional_create_request_attributes.map(&:to_s) - + @model_definition.nullable_attributes.map(&:to_s) + ).map { |key| key.to_s.camelize(:lower).to_sym } + } + + @schema_modifier.add_properties(schema, required_attributes, 'properties.data') + end + + # Generates the JSON schema for a update request. + # It generates a schema for the model attributes and then modifies it based on the additional and excluded attributes for update requests. + # It also determines the required attributes based on the optional and nullable attributes. + # Note that it is presumed that the model is using the same fields/columns for update as well as responses. + # + # @example + # schema = generator.generate_for_update + # + # @return [Hash] The generated schema for update requests. + def generate_for_update + schema = { + type: :object, + properties: { + data: AttributeSchemaGenerator.new(@model_definition).generate + } + } + + @schema_modifier.add_properties(schema, @model_definition.additional_update_request_attributes, 'properties.data.properties') + + @model_definition.excluded_update_request_attributes.each do |key| + @schema_modifier.delete_properties(schema, "properties.data.properties.#{key}") + end + + required_attributes = { + required: ( + schema.as_json['properties']['data']['properties'].keys - + @model_definition.optional_update_request_attributes.map(&:to_s) - + @model_definition.nullable_attributes.map(&:to_s) + ).map { |key| key.to_s.camelize(:lower).to_sym } + } + + @schema_modifier.add_properties(schema, required_attributes, 'properties.data') + end + end +end diff --git a/lib/schemable/response_schema_generator.rb b/lib/schemable/response_schema_generator.rb new file mode 100644 index 0000000..1b5895f --- /dev/null +++ b/lib/schemable/response_schema_generator.rb @@ -0,0 +1,128 @@ +module Schemable + # The ResponseSchemaGenerator class is responsible for generating JSON schemas for responses. + # This class generates schemas based on the model definition, including attributes, relationships, and included resources. + # + # @see Schemable + class ResponseSchemaGenerator + attr_reader :model_definition, :model, :schema_modifier, :configuration + + # Initializes a new ResponseSchemaGenerator instance. + # + # @param model_definition [ModelDefinition] The model definition to generate the schema for. + # + # @example + # generator = ResponseSchemaGenerator.new(model_definition) + def initialize(model_definition) + @model_definition = model_definition + @model = model_definition.model + @schema_modifier = SchemaModifier.new + @configuration = Schemable.configuration + end + + # Generates the JSON schema for a response. + # It generates a schema for the model attributes and relationships, and if the 'expand' option is true, + # it also includes the included resources in the schema. + # It also adds meta and jsonapi information to the schema. + # + # @param expand [Boolean] Whether to include the included resources in the schema. + # @param relationships_to_exclude_from_expansion [Array] The relationships to exclude from expansion in the schema. + # @param collection [Boolean] Whether the response is for a collection of resources. + # @param expand_nested [Boolean] Whether to include the nested relationships in the schema. + # + # @example + # schema = generator.generate(expand: true, relationships_to_exclude_from_expansion: ['some_relationship'], collection: true, expand_nested: true) + # + # @return [Hash] The generated schema. + def generate(expand: false, relationships_to_exclude_from_expansion: [], collection: false, expand_nested: false) + # Override expand_nested if infer_expand_nested_from_expand is true + expand_nested = expand if @configuration.infer_expand_nested_from_expand + + data = { + type: :object, + properties: { + type: { type: :string, default: @model_definition.model_name }, + id: { type: :string }, + attributes: AttributeSchemaGenerator.new(@model_definition).generate + }.merge( + if @model_definition.relationships.blank? || @model_definition.relationships == { belongs_to: {}, has_many: {} } + {} + else + { relationships: RelationshipSchemaGenerator.new(@model_definition).generate(expand:, relationships_to_exclude_from_expansion:) } + end + ) + } + + schema = collection ? { data: { type: :array, items: data } } : { data: } + + if expand + included_schema = IncludedSchemaGenerator.new(@model_definition).generate(expand: expand_nested, relationships_to_exclude_from_expansion:) + @schema_modifier.add_properties(schema, included_schema, '.') + end + + @schema_modifier.add_properties(schema, { meta: }, '.') if collection + @schema_modifier.add_properties(schema, { jsonapi: }, '.') + + { type: :object, properties: schema }.compact_blank + end + + # Generates the JSON schema for the 'meta' part of a response. + # It returns a custom meta response schema if one is defined in the configuration, otherwise it generates a default meta schema. + # + # @example + # meta_schema = generator.meta + # + # @return [Hash] The generated schema for the 'meta' part of a response. + def meta + return @configuration.custom_meta_response_schema if @configuration.custom_meta_response_schema.present? + + if @configuration.pagination_enabled + { + type: :object, + properties: { + page: { + type: :object, + properties: { + totalPages: { + type: :integer, + default: 1 + }, + count: { + type: :integer, + default: 1 + }, + rowsPerPage: { + type: :integer, + default: 1 + }, + currentPage: { + type: :integer, + default: 1 + } + } + } + } + } + else + {} + end + end + + # Generates the JSON schema for the 'jsonapi' part of a response. + # + # @example + # jsonapi_schema = generator.jsonapi + # + # @return [Hash] The generated schema for the 'jsonapi' part of a response. + def jsonapi + { + type: :object, + properties: { + version: { + type: :string, + default: '1.0' + } + } + } + end + end +end diff --git a/lib/schemable/schema_modifier.rb b/lib/schemable/schema_modifier.rb new file mode 100644 index 0000000..77cae40 --- /dev/null +++ b/lib/schemable/schema_modifier.rb @@ -0,0 +1,248 @@ +module Schemable + # The SchemaModifier class provides methods for modifying a given schema. + # It includes methods for parsing paths, checking if a path exists in a schema, + # deeply merging hashes, adding properties to a schema, and deleting properties from a schema. + # + # @see Schemable + class SchemaModifier + + # Parses a given path into an array of symbols. + # + # @note This method accepts paths in the following formats: + # - 'path.to.property' + # - 'path.to.array.[0].property' + # + # @example + # parse_path('path.to.property') #=> [:path, :to, :property] + # parse_path('path.to.array.[0].property') #=> [:path, :to, :array, :[0], :property] + # + # @param path [String] The path to parse. + # @return [Array] The parsed path. + def parse_path(path) + path.split('.').map(&:to_sym) + end + + # Checks if a given path exists in a schema. + # + # @example + # schema = { + # path: { + # type: :object, + # properties: { + # to: { + # type: :object, + # properties: { + # property: { + # type: :string + # } + # } + # } + # } + # } + # } + # + # path = 'path.properties.to.properties.property' + # incorrect_path = 'path.properties.to.properties.invalid' + # path_exists?(schema, path) #=> true + # path_exists?(schema, incorrect_path) #=> false + # + # @param schema [Hash, Array] The schema to check. + # @param path [String] The path to check for. + # @return [Boolean] True if the path exists in the schema, false otherwise. + def path_exists?(schema, path) + path_segments = parse_path(path) + + path_segments.reduce(schema) do |current_segment, next_segment| + if current_segment.is_a?(Array) + # The regex pattern '/\[(\d+)\]|\d+/' matches square brackets containing one or more digits, + # or standalone digits. Used for parsing array indices in a path. + index = next_segment.to_s.match(/\[(\d+)\]|\d+/)[1] + # The regex pattern '/\A\d+\z/' matches a sequence of one or more digits from the start ('\A') + # to the end ('\z') of a string. It checks if a string consists of only digits. + return false if index.nil? || !index.match?(/\A\d+\z/) || index.to_i >= current_segment.length + + current_segment[index.to_i] + else + return false unless current_segment.is_a?(Hash) && current_segment.key?(next_segment) + + current_segment[next_segment] + end + end + + true + end + + # Deeply merges two hashes. + # + # @example + # destination = { level1: { level2: { level3: 'value' } } } + # new_data = { level1_again: 'value' } + # deep_merge_hashes(destination, new_data) + # #=> { level1: { level2: { level3: 'value' } }, level1_again: 'value' } + # + # new_destination = [{ object1: 'value' }, { object2: 'value' }] + # new_new_data = { object3: 'value' } + # deep_merge_hashes(new_destination, new_new_data) + # #=> [{ object1: 'value' }, { object2: 'value' }, { object3: 'value' }] + # + # new_destination = { object1: 'value' } + # new_new_data = [{ object2: 'value' }, { object3: 'value' }] + # deep_merge_hashes(new_destination, new_new_data) + # #=> { object1: 'value', object2: 'value', object3: 'value' } + # + # @param destination [Hash] The hash to merge into. + # @param new_data [Hash] The hash to merge from. + # @return [Hash] The merged hashes. + def deep_merge_hashes(destination, new_data) + if destination.is_a?(Hash) && new_data.is_a?(Array) + destination.merge(new_data) + elsif destination.is_a?(Array) && new_data.is_a?(Hash) + destination.push(new_data) + elsif destination.is_a?(Hash) && new_data.is_a?(Hash) + new_data.each do |key, value| + if destination[key].is_a?(Hash) && value.is_a?(Hash) + destination[key] = deep_merge_hashes(destination[key], value) + elsif destination[key].is_a?(Array) && value.is_a?(Array) + destination[key].concat(value) + elsif destination[key].is_a?(Array) && value.is_a?(Hash) + destination[key].push(value) + else + destination[key] = value + end + end + end + + destination + end + + # Adds properties to a schema at a given path. + # + # @example + # original_schema = { level1: { level2: { level3: 'value' } } } + # new_data = { L3: 'value' } + # path = 'level1.level2' + # add_properties(original_schema, new_schema, path) + # #=> { level1: { level2: { level3: 'value', L3: 'value' } } } + # + # new_original_schema = { test: [{ object1: 'value' }, { object2: 'value' }] } + # new_new_schema = { object2_again: 'value' } + # path = 'test.[1]' + # add_properties(new_original_schema, new_new_schema, path) + # #=> { test: [{ object1: 'value' }, { object2: 'value', object2_again: 'value' }] } + # + # @param original_schema [Hash] The original schema. + # @param new_schema [Hash] The new schema to add. + # @param path [String] The path at which to add the new schema. + # @note This method accepts paths in the following formats: + # - 'path.to.property' + # - 'path.to.array.[0].property' + # - '.' + # + # @return [Hash] The modified schema. + def add_properties(original_schema, new_schema, path) + return deep_merge_hashes(original_schema, new_schema) if path == '.' + + unless path_exists?(original_schema, path) + puts "Error: Path '#{path}' does not exist in the original schema" + return original_schema + end + + path_segments = parse_path(path) + current_segment = original_schema + last_segment = path_segments.pop + + # Navigate to the specified location in the schema + path_segments.each do |segment| + if current_segment.is_a?(Array) + index = segment.to_s.match(/\[(\d+)\]|\d+/)[1] + if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length + current_segment = current_segment[index.to_i] + else + puts "Error: Invalid index in path '#{path}'" + return original_schema + end + elsif current_segment.is_a?(Hash) && current_segment.key?(segment) + current_segment = current_segment[segment] + else + puts "Error: Expected a Hash but found #{current_segment.class} in path '#{path}'" + return original_schema + end + end + + # Merge the new schema into the specified location + if current_segment.is_a?(Array) + index = last_segment.to_s.match(/\[(\d+)\]|\d+/)[1] + if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length + current_segment[index.to_i] = deep_merge_hashes(current_segment[index.to_i], new_schema) + else + puts "Error: Invalid index in path '#{path}'" + end + else + current_segment[last_segment] = deep_merge_hashes(current_segment[last_segment], new_schema) + end + + original_schema + end + + # Deletes properties from a schema at a given path. + # + # @example + # original_schema = { level1: { level2: { level3: 'value' } } } + # path = 'level1.level2' + # delete_properties(original_schema, path) + # #=> { level1: {} } + # + # new_original_schema = { test: [{ object1: 'value' }, { object2: 'value' }] } + # path = 'test.[1]' + # delete_properties(new_original_schema, path) + # #=> { test: [{ object1: 'value' }] } + # + # @param original_schema [Hash] The original schema. + # @param path [String] The path at which to delete properties. + # @return [Hash] The modified schema. + def delete_properties(original_schema, path) + return original_schema if path == '.' + + unless path_exists?(original_schema, path) + puts "Error: Path '#{path}' does not exist in the original schema" + return original_schema + end + + path_segments = parse_path(path) + current_segment = original_schema + last_segment = path_segments.pop + + # Navigate to the parent of the last segment in the path + path_segments.each do |segment| + if current_segment.is_a?(Array) + index = segment.to_s.match(/\[(\d+)\]|\d+/)[1] + if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length + current_segment = current_segment[index.to_i] + else + puts "Error: Invalid index in path '#{path}'" + return original_schema + end + elsif current_segment.is_a?(Hash) && current_segment.key?(segment) + current_segment = current_segment[segment] + else + puts "Error: Expected a Hash but found #{current_segment.class} in path '#{path}'" + return original_schema + end + end + + # Delete the last segment in the path + if current_segment.is_a?(Array) + index = last_segment.to_s.match(/\[(\d+)\]|\d+/)[1] + if index&.match?(/\A\d+\z/) && index.to_i < current_segment.length + current_segment.delete_at(index.to_i) + else + puts "Error: Invalid index in path '#{path}'" + end + else + current_segment.delete(last_segment) + end + + original_schema + end + end +end diff --git a/lib/schemable/version.rb b/lib/schemable/version.rb index daf5918..f2ec300 100644 --- a/lib/schemable/version.rb +++ b/lib/schemable/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Schemable - VERSION = "0.1.2" + VERSION = '1.0.3' end diff --git a/lib/templates/common_definitions.rb b/lib/templates/common_definitions.rb deleted file mode 100644 index ef54eda..0000000 --- a/lib/templates/common_definitions.rb +++ /dev/null @@ -1,13 +0,0 @@ -module SwaggerDefinitions - module CommonDefinitions - def self.aggregate - [ - # Import definitions like this: - # Swagger::Definitions::Model.definitions - - # Make sure in swagger_helper.rb's components section you have: - # schemas: SwaggerDefinitions::CommonDefinitions.aggregate - ].flatten.reduce({}, :merge) - end - end -end diff --git a/lib/templates/schemable.rb b/lib/templates/schemable.rb new file mode 100644 index 0000000..6400b46 --- /dev/null +++ b/lib/templates/schemable.rb @@ -0,0 +1,81 @@ +Schemable.configure do |config| + # The following options are available for configuration. + # If you do not specify a configuration option, then its default value will be used. + # To configure them, uncomment them and set them to the desired value. + + # The ORM options are :active_record, :mongoid + # + # config.orm = :active_record + + # The gem uses `{ type: :number, format: :float }` for float attributes by default. + # If you want to use `{ type: :string }` instead, set this option to true. + # + # config.float_as_string = false + + # The gem uses `{ type: :number, format: :decimal }` for decimal attributes by default. + # If you want to use `{ type: :string }` instead, set this option to true. + # + # config.decimal_as_string = false + + # The gem by default sets the pagination_enabled option to true + # which means in the meta section of the response schema + # it will add the pagination links and the total count + # if you don't want to have the pagination links and the total count + # in the meta section of the response schema, set this option to false + # If you want to define your own meta schema, you can set the custom_meta_response_schema option + # + # config.pagination_enabled = true + # + # config.custom_meta_response_schema = nil + + # The gem allows for custom defined schema for a specific type + # for example if you wish to have all your arrays have the schema + # { type: :array, items: { type: string } } then use the below method to add to custom_type_mappers + # + # config.add_custom_type_mapper(:array, { type: :array, items: { type: string } }) + + # If you have a custom enum method defined on all of your model, you can set it here + # for example if you have a method called `base_attributes` on all of your models + # and you use that method to return an array of symbols that are the attributes + # to be serialized then you can set the below to `base_attributes` + # + # config.infer_attributes_from_custom_method = nil + + # If you want to recursively expand the relationships in the response schema + # then set this option to true, otherwise set it to false (default). + # + # config.infer_expand_nested_from_expand = true + + # If you want to get the list of attributes from the jsonapi-rails gem's + # JSONAPI::Serializable::Resource class, set this option to true. + # It uses the attribute_blocks method to get the list of attributes. + # + # config.infer_attributes_from_jsonapi_serializable = false + + # Sometimes you may have virtual attributes that are not in the database + # Generating the schema for these attributes will fail, in that case you can + # add your logic to return an instance of the model that is serialized in + # jsonapi format and the gem will use that to generate the schema + # this is useful if you use factory_bot and jsonapi-rails to generate the instance + # check the commented out code in the definition template for an example + # Set this option to true to enable this feature + # + # config.use_serialized_instance = false + + # By default the gem uses activerecord's defined_enums method to get the enums + # with their keys and values, if you don't have this method defined on your model + # then please set the below option to the name of the method that returns the + # enums with their keys and values as a hash. This will handle the auto generation + # of the enum schema for you, with correct values. + # + # config.custom_defined_enum_method = nil + + # If you use mongoid and simple_enum gem, you can set the below options to the prefix and suffix + # Since simple_enum uses the prefix and suffix to generate the enum methods, and the fields' names + # are usually the enum name with the prefix and suffix, the gem will remove the prefix and suffix + # from the field name to get the enum name and then use that to get the enum values + # + # config.enum_prefix_for_simple_enum = nil + # + # config.enum_suffix_for_simple_enum = nil +end diff --git a/lib/templates/serializers_helper.rb b/lib/templates/serializers_helper.rb deleted file mode 100644 index 902740d..0000000 --- a/lib/templates/serializers_helper.rb +++ /dev/null @@ -1,7 +0,0 @@ -module SerializersHelper - def serializers_map - { - # TheModel: V1::TheModelSerializer - }.freeze - end -end diff --git a/schemable.gemspec b/schemable.gemspec index d8fd544..a52a073 100644 --- a/schemable.gemspec +++ b/schemable.gemspec @@ -1,37 +1,33 @@ # frozen_string_literal: true -require_relative "lib/schemable/version" +require_relative 'lib/schemable/version' Gem::Specification.new do |spec| - spec.name = "schemable" + spec.name = 'schemable' spec.version = Schemable::VERSION - spec.authors = ["Muhammad Nawzad"] - spec.email = ["hama127n@gmail.com"] + spec.authors = ['Muhammad Nawzad'] + spec.email = ['hama127n@gmail.com'] - spec.summary = "An opiniated Gem for Rails applications to auto generate schema in JSONAPI format." - spec.description = "The schemable gem is an opiniated Gem for Rails applications to auto generate schema for models in JSONAPI format. It is designed to work with rswag's swagger documentation since it can generate the schemas for it." - spec.homepage = "https://github.com/muhammadnawzad/schemable" - spec.license = "MIT" - spec.required_ruby_version = ">= 3.1.2" + spec.summary = 'An opinionated Gem for Rails applications to auto generate schema in JSONAPI format.' + spec.description = "The schemable gem is an opinionated Gem for Rails applications to auto generate schema for models in JSONAPI format. It is designed to work with rswag's swagger documentation since it can generate the schemas for it." + spec.homepage = 'https://github.com/muhammadnawzad/schemable' + spec.license = 'MIT' + spec.required_ruby_version = '>= 3.1.2' - spec.metadata["allowed_push_host"] = 'https://rubygems.org' - - spec.metadata["homepage_uri"] = spec.homepage - spec.metadata["source_code_uri"] = 'https://github.com/muhammadnawzad/schemable' - spec.metadata["changelog_uri"] = 'https://github.com/muhammadnawzad/schemable/blob/main/CHANGELOG.md' + spec.metadata['allowed_push_host'] = 'https://rubygems.org' + spec.metadata['homepage_uri'] = spec.homepage + spec.metadata['source_code_uri'] = 'https://github.com/muhammadnawzad/schemable' + spec.metadata['changelog_uri'] = 'https://github.com/muhammadnawzad/schemable/blob/main/CHANGELOG.md' spec.files = Dir.chdir(__dir__) do `git ls-files -z`.split("\x0").reject do |f| (File.expand_path(f) == __FILE__) || f.start_with?(*%w[bin/ test/ spec/ features/ .git .circleci appveyor]) end end - spec.bindir = "exe" + spec.bindir = 'exe' spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } - spec.require_paths = ["lib"] - - spec.add_dependency "jsonapi-rails", "~> 0.4.1" - spec.add_dependency "factory_bot_rails", "~> 6.2.0" + spec.require_paths = ['lib'] spec.metadata['rubygems_mfa_required'] = 'true' end diff --git a/sig/schemable.rbs b/sig/schemable.rbs index ec2c40f..1267727 100644 --- a/sig/schemable.rbs +++ b/sig/schemable.rbs @@ -1,4 +1,7 @@ module Schemable VERSION: String - # See the writing guide of rbs: https://github.com/ruby/rbs#guides + + attr_accessor configuration: Configuration + + def configure: () { (Configuration) -> Configuration } -> Configuration end diff --git a/sig/schemable/attribute_schema_generator.rbs b/sig/schemable/attribute_schema_generator.rbs new file mode 100644 index 0000000..4b950b1 --- /dev/null +++ b/sig/schemable/attribute_schema_generator.rbs @@ -0,0 +1,13 @@ +module Schemable + class AttributeSchemaGenerator + attr_reader model: Class + attr_reader model_definition: Definition + attr_reader configuration: Configuration + attr_reader response: Hash[Symbol, any]? + attr_reader schema_modifier: SchemaModifier + + def initialize: (Definition) -> void + def generate: -> (Hash[Symbol, any] | Array[any]) + def generate_attribute_schema: (Symbol) -> Hash[Symbol, any] + end +end diff --git a/sig/schemable/configuration.rbs b/sig/schemable/configuration.rbs new file mode 100644 index 0000000..02c2cb1 --- /dev/null +++ b/sig/schemable/configuration.rbs @@ -0,0 +1,22 @@ +module Schemable + class Configuration + attr_accessor orm: Symbol + attr_accessor float_as_string: bool + attr_accessor decimal_as_string: bool + attr_accessor pagination_enabled: bool + attr_accessor use_serialized_instance: bool + attr_accessor custom_defined_enum_method: Symbol? + attr_accessor enum_prefix_for_simple_enum: String? + attr_accessor enum_suffix_for_simple_enum: String? + attr_accessor infer_expand_nested_from_expand: bool + attr_accessor custom_type_mappers: Hash[Symbol, any] + attr_accessor infer_attributes_from_custom_method: Symbol? + attr_accessor custom_meta_response_schema: Hash[Symbol, any]? + attr_accessor infer_attributes_from_jsonapi_serializable: bool + + + def initialize: -> void + def type_mapper: (Symbol) -> Hash[Symbol, any] + def add_custom_type_mapper: (Symbol, Hash[Symbol, any]) -> void + end +end diff --git a/sig/schemable/definition.rbs b/sig/schemable/definition.rbs new file mode 100644 index 0000000..a0b264d --- /dev/null +++ b/sig/schemable/definition.rbs @@ -0,0 +1,34 @@ +module Schemable + class Definition + attr_reader configuration: Configuration + attr_writer relationships: Hash[Symbol, any] + attr_writer additional_create_request_attributes: Hash[Symbol, any] + attr_writer additional_update_request_attributes: Hash[Symbol, any] + + def model: -> Class + def initialize: -> void + def serializer: -> Class? + def model_name: -> String + def attributes: -> Array[Symbol] + def array_types: -> Hash[Symbol, any] + def relationships: -> Hash[Symbol, any] + def nullable_attributes: -> Array[Symbol] + def nullable_relationships: -> Array[String] + def serialized_instance: -> Hash[Symbol, any] + def self.generate: -> Array[Hash[Symbol, any]] + def excluded_response_included: -> Array[Symbol] + def excluded_response_relations: -> Array[Symbol] + def excluded_response_attributes: -> Array[Symbol] + def additional_response_included: -> Hash[Symbol, any] + def additional_response_relations: -> Hash[Symbol, any] + def additional_response_attributes: -> Hash[Symbol, any] + def excluded_create_request_attributes: -> Array[Symbol] + def excluded_update_request_attributes: -> Array[Symbol] + def optional_create_request_attributes: -> Array[Symbol] + def optional_update_request_attributes: -> Array[Symbol] + def default_value_for_enum_attributes: -> Hash[Symbol, any] + def additional_create_request_attributes: -> Hash[Symbol, any] + def additional_update_request_attributes: -> Hash[Symbol, any] + def camelize_keys: (Hash[Symbol, any]) -> (Array[Hash[Symbol, any]] | Hash[Symbol, any]) + end +end diff --git a/sig/schemable/included_schema_generator.rbs b/sig/schemable/included_schema_generator.rbs new file mode 100644 index 0000000..616b146 --- /dev/null +++ b/sig/schemable/included_schema_generator.rbs @@ -0,0 +1,11 @@ +module Schemable + class IncludedSchemaGenerator + attr_reader model_definition: Definition + attr_reader schema_modifier: SchemaModifier + attr_reader relationships: Hash[Symbol, any] + + def initialize: (Definition) -> void + def generate: (?relationships_to_exclude_from_expansion: Array[String], ?expand: bool) -> (Hash[Symbol, any]) + def prepare_schema_for_included: (Definition, ?relationships_to_exclude_from_expansion: Array[String], ?expand: bool) -> Hash[Symbol, any] + end +end diff --git a/sig/schemable/relationship_schema_generator.rbs b/sig/schemable/relationship_schema_generator.rbs new file mode 100644 index 0000000..8d21c70 --- /dev/null +++ b/sig/schemable/relationship_schema_generator.rbs @@ -0,0 +1,11 @@ +module Schemable + class RelationshipSchemaGenerator + attr_reader model_definition: Definition + attr_reader schema_modifier: SchemaModifier + attr_reader relationships: Hash[Symbol, any] + + def initialize: (Definition) -> void + def generate_schema: (String, ?collection: bool) -> Hash[Symbol, any] + def generate: (?relationships_to_exclude_from_expansion: Array[String], ?expand: bool) -> (Hash[Symbol, any]) + end +end diff --git a/sig/schemable/request_schema_generator.rbs b/sig/schemable/request_schema_generator.rbs new file mode 100644 index 0000000..64b719b --- /dev/null +++ b/sig/schemable/request_schema_generator.rbs @@ -0,0 +1,10 @@ +module Schemable + class RequestSchemaGenerator + attr_reader model_definition: Definition + attr_reader schema_modifier: SchemaModifier + + def initialize: (Definition) -> void + def generate_for_create: () -> (Hash[Symbol, any]) + def generate_for_update: () -> (Hash[Symbol, any]) + end +end diff --git a/sig/schemable/response_schema_generator.rbs b/sig/schemable/response_schema_generator.rbs new file mode 100644 index 0000000..0ff9c97 --- /dev/null +++ b/sig/schemable/response_schema_generator.rbs @@ -0,0 +1,13 @@ +module Schemable + class ResponseSchemaGenerator + attr_reader model: Class + attr_reader model_definition: Definition + attr_reader schema_modifier: SchemaModifier + attr_reader configuration: Configuration + + def initialize: (Definition) -> void + def meta: -> Hash[Symbol, any] + def jsonapi: -> Hash[Symbol, any] + def generate: (expand: bool, relationships_to_exclude_from_expansion: Array[Symbol], collection: bool, expand_nested: bool) -> Hash[Symbol, any] + end +end diff --git a/sig/schemable/schema_modifier.rbs b/sig/schemable/schema_modifier.rbs new file mode 100644 index 0000000..9dfe2ca --- /dev/null +++ b/sig/schemable/schema_modifier.rbs @@ -0,0 +1,9 @@ +module Schemable + class SchemaModifier + def parse_path: (path: String) -> Array[Symbol] + def path_exists?: (schema: Hash[Symbol, any], path: String) -> bool + def deep_merge_hashes: (destination: Hash[Symbol, any], new_data: Hash[Symbol, any]) -> (Hash[Symbol, any]) + def add_properties: (original_schema: (Hash[Symbol, any]), new_schema: Hash[Symbol, any], path: String) -> (Hash[Symbol, any]) + def delete_properties: (original_schema: (Hash[Symbol, any]), path: String) -> (Hash[Symbol, any]) + end +end diff --git a/spec/schemable_spec.rb b/spec/schemable_spec.rb index 9f994c3..1b7808f 100644 --- a/spec/schemable_spec.rb +++ b/spec/schemable_spec.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true RSpec.describe Schemable do - it "has a version number" do + it 'has a version number' do expect(Schemable::VERSION).not_to be nil end - it "does something useful" do + it 'does something useful' do expect(true).to eq(true) end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 433f41d..abd54ea 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require "schemable" +require 'schemable' RSpec.configure do |config| # Enable flags like --only-failures and --next-failure - config.example_status_persistence_file_path = ".rspec_status" + config.example_status_persistence_file_path = '.rspec_status' # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching!