diff --git a/.rubocop.yml b/.rubocop.yml index 962e0df..d800eb3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -39,3 +39,14 @@ Style/FrozenStringLiteralComment: Enabled: true EnforcedStyle: always_true SafeAutoCorrect: true + +Style/RequireOrder: + Enabled: true + SafeAutoCorrect: true + +Style/AccessModifierDeclarations: + Enabled: true + EnforcedStyle: inline + +Style/RedundantReturn: + Enabled: false diff --git a/Gemfile b/Gemfile index 316b434..3cacbd5 100644 --- a/Gemfile +++ b/Gemfile @@ -17,3 +17,7 @@ group :lint do gem 'rubocop' gem 'steep' end + +group :rbs do + gem 'orthoses' +end diff --git a/Gemfile.lock b/Gemfile.lock index 9e06cb2..b68e54e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -97,6 +97,8 @@ GEM openapi_parameters (0.3.2) rack (>= 2.2) zeitwerk (~> 2.6) + orthoses (1.13.0) + rbs (~> 3.0) parallel (1.24.0) parser (3.3.0.0) ast (~> 2.4.1) @@ -214,6 +216,7 @@ PLATFORMS DEPENDENCIES debug openapi_rails_typed_parameters! + orthoses rake rspec rspec-rails diff --git a/README.md b/README.md index dbc7d32..e333233 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,62 @@ Add `openapi_rails_typed_parameters` to your Gemfile. gem 'openapi_rails_typed_parameters' ``` +Then, install rake task by generator. + +```sh +bin/rails g openapi_rails_typed_parameters:install +``` + +## Usage + +Run `openapi_rails_typed_parameters:generate` rake task. + +```sh +bin/rails openapi_rails_typed_parameters:generate +``` + +If you update your OpenAPI definition, then generate again with `--force` option. It overwrites RBSs. + +```sh +bin/rails openapi_rails_typed_parameters:generate --force +``` + ## Usage +Add `using OpenapiRailsTypedParameters` to your controller class. You can access statically typed parameters via `typed_params` method. + +```rb +class UsersController < ApplicationController + using OpenapiRailsTypedParameters + + def index + # BEFORE: Default Rails code. + params.permit(:role) + role_string = params[:role] + role = + if role_string.present? + if ['admin', 'maintainer', 'member'].include?(role_string) + role_string.to_sym + else + raise 'Unknown `role` passed. available values are: [admin, maintainer, member].' + end + else + :member # fallback to default + end + + # AFTER: Typed parameters way. + # role is validated, and it's type coerced. + role = typed_params.query_params.role # :admin, :maintainer or :member + + @users = User.where(role:) + render :index + end +end + +``` + +## RBS generation, static typing + Please add an initializer to your Rails application and specify the path to the OpenAPI schema file. e.g.) `config/initializers/openapi.rb` @@ -50,7 +104,29 @@ OpenapiRailsTypedParameters.configure do |config| end ``` -Then, add `using OpenapiRailsTypedParameters` to your controller class. You can access statically typed parameters via `typed_parameters` method. +If you want to customize generator behavior, edit `lib/tasks/openapi_rails_typed_parameters.rake`. + +Then, you can use statically typed parameters. Please use `typed_params_for(:action_name)` instead of `typed_params`. + +Enjoy statically typed params with your favorite LSP server. + +```rb +class UsersController < ApplicationController + using OpenapiRailsTypedParameters + + def index + # BEFORE: RBS not injected. + _ = typed_params + + # AFTER: RBS injected. + _ = typed_params_for(:index) + + role = typed_params_for(:index).query_params.role + @users = User.where(role:) + render :index + end +end +``` ## Example @@ -70,7 +146,8 @@ paths: required: true schema: type: string - enum: [ admin, maintainer ] + enum: [ admin, maintainer, member ] + default: member - name: minimum in: query required: false diff --git a/Rakefile b/Rakefile index 4964751..92a88c0 100644 --- a/Rakefile +++ b/Rakefile @@ -10,3 +10,47 @@ require 'rubocop/rake_task' RuboCop::RakeTask.new task default: %i[spec rubocop] + +desc 'Generate RBS' +task :generate_rbs do + require 'active_support/testing/stream' + require 'orthoses' + require_relative 'lib/openapi_rails_typed_parameters' + + namespace = OpenapiRailsTypedParameters.to_s + + out = Pathname(__FILE__).dirname / 'sig/generated' + + begin + out.rmtree + rescue StandardError + nil + end + + Orthoses.logger.level = :warn + Orthoses::Builder.new do + use Orthoses::CreateFileByName, + to: 'sig/generated', + rmtree: true, + header: '# Generated code' + use Orthoses::Filter do |name, _content| + name.start_with?(namespace) + end + use Orthoses::Mixin + use Orthoses::Constant + use Orthoses::Trace, + patterns: [namespace, "#{namespace}::*"] + use Orthoses::RBSPrototypeRB, + paths: Dir.glob('lib/**/*.rb') + run lambda { + _ = OpenapiRailsTypedParameters::VERSION + Class.new.extend(ActiveSupport::Testing::Stream).instance_exec do + silence_stream($stdout) do + require 'rspec' + spec_files = Dir.glob('spec/**/*_spec.rb') + RSpec::Core::Runner.run(spec_files) + end + end + } + end.call +end diff --git a/Steepfile b/Steepfile new file mode 100644 index 0000000..e9fcc21 --- /dev/null +++ b/Steepfile @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +D = Steep::Diagnostic + +target :lib do + signature 'sig' + + check 'lib' + check 'Gemfile' + check 'Rakefile' + # configure_code_diagnostics(D::Ruby.default) # `default` diagnostics setting (applies by default) + # configure_code_diagnostics(D::Ruby.strict) # `strict` diagnostics setting + # configure_code_diagnostics(D::Ruby.lenient) # `lenient` diagnostics setting + # configure_code_diagnostics(D::Ruby.silent) # `silent` diagnostics setting +end + +target :spec do + signature 'sig' + + check 'spec' +end diff --git a/bin/rdbg b/bin/rdbg new file mode 100755 index 0000000..5e3b279 --- /dev/null +++ b/bin/rdbg @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rdbg' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("debug", "rdbg") diff --git a/bin/rspec b/bin/rspec new file mode 100755 index 0000000..cb53ebe --- /dev/null +++ b/bin/rspec @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'rspec' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("rspec-core", "rspec") diff --git a/lib/generators/openapi_rails_typed_parameters/install_generator.rb b/lib/generators/openapi_rails_typed_parameters/install_generator.rb new file mode 100644 index 0000000..0882c92 --- /dev/null +++ b/lib/generators/openapi_rails_typed_parameters/install_generator.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'rails' + +module OpenapiRailsTypedParameters + class InstallGenerator < Rails::Generators::Base + create_file 'lib/tasks/openapi_rails_typed_parameters.rake', <<~RUBY + begin + require 'openapi_rails_typed_parameters/rake_task' + + OpenapiRailsTypedParameters::RakeTask.new do |task| + # Base path for RBS generation. + # default: Rails.root / 'sig/openapi_rails_typed_parameters' + # task.sig_root_dir = Rails.root / 'sig/openapi_rails_typed_parameters' + end + rescue LoadError + # failed to load openapi_rails_typed_parameters. Skip to load openapi_rails_typed_parameters tasks. + end + RUBY + end +end diff --git a/lib/openapi_rails_typed_parameters.rb b/lib/openapi_rails_typed_parameters.rb index 7dbfb0c..7a1707b 100644 --- a/lib/openapi_rails_typed_parameters.rb +++ b/lib/openapi_rails_typed_parameters.rb @@ -4,4 +4,5 @@ require_relative 'openapi_rails_typed_parameters/configuration' require_relative 'openapi_rails_typed_parameters/handler' require_relative 'openapi_rails_typed_parameters/railtie' +require_relative 'openapi_rails_typed_parameters/type_generator' require_relative 'openapi_rails_typed_parameters/version' diff --git a/lib/openapi_rails_typed_parameters/rake_task.rb b/lib/openapi_rails_typed_parameters/rake_task.rb new file mode 100644 index 0000000..6348b2e --- /dev/null +++ b/lib/openapi_rails_typed_parameters/rake_task.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'optparse' +require 'rails' +require 'rake' +require 'rake/tasklib' + +module OpenapiRailsTypedParameters + class RakeTask < Rake::TaskLib + attr_accessor :name + attr_writer :sig_root_dir + + def initialize(name: :openapi_rails_typed_parameters, &block) + super() + + @name = name + @sig_root_dir = Rails.root / 'sig/openapi_rails_typed_parameters' + + block&.call(self) + + define_generate_task + end + + def define_generate_task + desc 'Generate RBS files for given OpenAPI schema' + task("#{name}:generate": :environment) do + require 'openapi_rails_typed_parameters' + type_generator = OpenapiRailsTypedParameters::TypeGenerator.new + rbs = type_generator.generate_rbs + file_path = File.join(@sig_root_dir, 'action_controller.rbs') + + options = parse_options(argv: ARGV) + + if File.exist?(file_path) && options[:force] == false + abort "RBS file '#{file_path}' already exists. use `--force` option to overwrite." + else + File.write(file_path, rbs) + end + end + end + + private def parse_options(argv:) + options = {} + + option_parser = OptionParser.new + option_parser.banner = 'Usage: openapi_rails_typed_parameters:generate [options]' + option_parser.on('-f', '--force', FalseClass, 'Force overwrite RBS file if it\'s already exists.') + args = option_parser.order(argv) + option_parser.parse(args, into: options) + + options + end + end +end diff --git a/lib/openapi_rails_typed_parameters/type_generator.rb b/lib/openapi_rails_typed_parameters/type_generator.rb new file mode 100644 index 0000000..94ec059 --- /dev/null +++ b/lib/openapi_rails_typed_parameters/type_generator.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'rbs' + +module OpenapiRailsTypedParameters + class TypeGenerator + private attr_accessor :config + private attr_accessor :validator + + def initialize + @config = OpenapiRailsTypedParameters.configuration + @validator = OpenapiFirst.load(@config.schema_path) + end + + def generate_rbs + rbs = <<~RBS + #{generate_parameter_definitions} + #{generate_controller_definitions} + RBS + return format(rbs) + end + + def operation_to_type_name(operation:) + # TODO: Use same naming as Rails path helper + verb = operation.method + path = + operation + .path + .scan(/[\w_]+/) + .join('_') + return "#{verb}_#{path}" + end + + def generate_parameter_definitions + definitions = [] + + operations = validator.operations + operations.each do |operation| + type_name = operation_to_type_name(operation:) + + path_params_rbs = + operation + .path_parameters + &.parameters + .then { _1 || [] } + .map do |param| + type = param.schema['type'].camelize + optional = param.required? ? '' : '?' + "#{param.name}: #{type}#{optional}" + end + .join(",\n") + + query_params_rbs = + operation + .query_parameters + &.parameters + .then { _1 || [] } + .map do |param| + type = param.schema['type'].camelize + optional = param.required? ? '' : '?' + "#{param.name}: #{type}#{optional}" + end + .join(",\n") + + definitions << <<~RBS + type #{type_name} = { + path_params: { #{path_params_rbs} }, + query_params: { #{query_params_rbs} }, + body: __todo__, + valid: bool + } + RBS + end + + # return 'type hoge = {hi: Integer}' + rbs = format(definitions.join) + return rbs + end + + def generate_controller_definitions + Rails.application.eager_load! + route_inspector = ActionDispatch::Routing::RoutesInspector.new(Rails.application.routes.routes) + journy_routes = route_inspector.instance_variable_get(:@routes) + + # @type var params_definitions: Hash[String, Hash[String, untyped]] + params_definitions = {} + validator.operations.each do |operation| + puts "Find: #{operation.method} #{operation.path}" + path = journy_routes.find do |route| + route.path.match?(operation.path) && route.verb.downcase.to_sym == operation.method.to_sym + end + + # path not found + next unless path + + controller_name = "#{path.defaults[:controller]}_controller".camelize + action_name = path.defaults[:action] + + params_definitions[controller_name] ||= {} + params_definitions[controller_name][action_name] = operation + end + + lines = [] + params_definitions + .sort_by { |controller_name, _| controller_name } + .map do |controller_name, action_definitions| + lines << "class #{controller_name}" + action_definitions + .sort_by { |action_name, _| action_name } + .each.with_index do |(action_name, operation), i| + type_name = operation_to_type_name(operation:) + lines << if i.zero? + " def self.typed_params_for: (:#{action_name}) -> #{type_name}" + else + " | (:#{action_name}) -> #{type_name}" + end + end + lines << 'end' + end + rbs = format(lines.join("\n")) + return rbs + end + + def format(rbs) + signature = RBS::Parser.parse_signature(rbs) + stream = StringIO.new + writer = RBS::Writer.new(out: stream) + writer.write(signature[1] + signature[2]) + formatted = + stream + .string + # remove multiple newlines + .gsub(/\n{2,}/, "\n") + stream.close + return formatted + end + end +end diff --git a/rbs_collection.lock.yaml b/rbs_collection.lock.yaml new file mode 100644 index 0000000..42fb757 --- /dev/null +++ b/rbs_collection.lock.yaml @@ -0,0 +1,284 @@ +--- +path: ".gem_rbs_collection" +gems: +- name: abbrev + version: '0' + source: + type: stdlib +- name: actionpack + version: '6.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: actionview + version: '6.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: activesupport + version: '7.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: ast + version: '2.4' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: base64 + version: '0' + source: + type: stdlib +- name: bigdecimal + version: '0' + source: + type: stdlib +- name: cgi + version: '0' + source: + type: stdlib +- name: concurrent-ruby + version: '1.1' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: connection_pool + version: '2.4' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: csv + version: '0' + source: + type: stdlib +- name: date + version: '0' + source: + type: stdlib +- name: erb + version: '0' + source: + type: stdlib +- name: fileutils + version: '0' + source: + type: stdlib +- name: forwardable + version: '0' + source: + type: stdlib +- name: i18n + version: '1.10' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: io-console + version: '0' + source: + type: stdlib +- name: json + version: '0' + source: + type: stdlib +- name: listen + version: '3.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: logger + version: '0' + source: + type: stdlib +- name: minitest + version: '0' + source: + type: stdlib +- name: monitor + version: '0' + source: + type: stdlib +- name: mutex_m + version: '0' + source: + type: stdlib +- name: nokogiri + version: '1.11' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: openapi_parameters + version: 0.3.2 + source: + type: rubygems +- name: optparse + version: '0' + source: + type: stdlib +- name: orthoses + version: 1.13.0 + source: + type: rubygems +- name: parallel + version: '1.20' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: parser + version: '3.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: pathname + version: '0' + source: + type: stdlib +- name: rack + version: '2.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rails-dom-testing + version: '2.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: railties + version: '6.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rainbow + version: '3.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rake + version: '13.0' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rbs + version: 3.4.1 + source: + type: rubygems +- name: rdoc + version: '0' + source: + type: stdlib +- name: regexp_parser + version: '2.8' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rubocop + version: '1.57' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: rubocop-ast + version: '1.30' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: securerandom + version: '0' + source: + type: stdlib +- name: singleton + version: '0' + source: + type: stdlib +- name: steep + version: 1.6.0 + source: + type: rubygems +- name: strscan + version: '0' + source: + type: stdlib +- name: tempfile + version: '0' + source: + type: stdlib +- name: thor + version: '1.2' + source: + type: git + name: ruby/gem_rbs_collection + revision: 846c09971455f0e144cef2f5a6c9fe6d8905d3e1 + remote: https://github.com/ruby/gem_rbs_collection.git + repo_dir: gems +- name: time + version: '0' + source: + type: stdlib +- name: timeout + version: '0' + source: + type: stdlib +- name: tsort + version: '0' + source: + type: stdlib +- name: uri + version: '0' + source: + type: stdlib +gemfile_lock_path: Gemfile.lock diff --git a/rbs_collection.yaml b/rbs_collection.yaml new file mode 100644 index 0000000..d6f0582 --- /dev/null +++ b/rbs_collection.yaml @@ -0,0 +1,19 @@ +# Download sources +sources: + - type: git + name: ruby/gem_rbs_collection + remote: https://github.com/ruby/gem_rbs_collection.git + revision: main + repo_dir: gems + +# You can specify local directories as sources also. +# - type: local +# path: path/to/your/local/repository + +# A directory to install the downloaded RBSs +path: .gem_rbs_collection + +gems: + - name: rbs + - name: pathname + - name: logger diff --git a/sig/configuration.rbs b/sig/configuration.rbs new file mode 100644 index 0000000..bbc3340 --- /dev/null +++ b/sig/configuration.rbs @@ -0,0 +1,5 @@ +class OpenapiRailsTypedParameters::Configuration + @schema_path: String + attr_accessor schema_path: String + private def initialize: () -> void +end diff --git a/sig/handler.rbs b/sig/handler.rbs new file mode 100644 index 0000000..37035b8 --- /dev/null +++ b/sig/handler.rbs @@ -0,0 +1,6 @@ +class OpenapiRailsTypedParameters::Handler + attr_accessor self.configuration: OpenapiRailsTypedParameters::Configuration + attr_accessor self.validator: untyped + def self.build_validator: () -> __todo__ + def self.build_validator_if_needed: () -> __todo__ +end diff --git a/sig/install_generator.rbs b/sig/install_generator.rbs new file mode 100644 index 0000000..725d6b1 --- /dev/null +++ b/sig/install_generator.rbs @@ -0,0 +1,2 @@ +class OpenapiRailsTypedParameters::InstallGenerator < ::Rails::Generators::Base +end diff --git a/sig/openapi_rails_typed_parameters.rbs b/sig/openapi_rails_typed_parameters.rbs index a55d3d1..22bde21 100644 --- a/sig/openapi_rails_typed_parameters.rbs +++ b/sig/openapi_rails_typed_parameters.rbs @@ -1,4 +1,9 @@ module OpenapiRailsTypedParameters + @@configuration: untyped + + def self.configuration: () -> OpenapiRailsTypedParameters::Configuration + + def self.configure: () -> nil + VERSION: String - # See the writing guide of rbs: https://github.com/ruby/rbs#guides end diff --git a/sig/railtie.rbs b/sig/railtie.rbs new file mode 100644 index 0000000..73e6a9e --- /dev/null +++ b/sig/railtie.rbs @@ -0,0 +1,2 @@ +class OpenapiRailsTypedParameters::Railtie < ::Rails::Railtie +end diff --git a/sig/rake_task.rbs b/sig/rake_task.rbs new file mode 100644 index 0000000..bb2f7e3 --- /dev/null +++ b/sig/rake_task.rbs @@ -0,0 +1,10 @@ +class OpenapiRailsTypedParameters::RakeTask < ::Rake::TaskLib + @name: String + @sig_root_dir: String + attr_accessor name: String + attr_writer sig_root_dir: String + + def initialize: (?name: Symbol) ?{ () -> untyped } -> void + def define_generate_task: () -> void + private def parse_options: (argv: Array[String]) -> { force: bool } +end diff --git a/sig/type_generator.rbs b/sig/type_generator.rbs new file mode 100644 index 0000000..205d3ba --- /dev/null +++ b/sig/type_generator.rbs @@ -0,0 +1,12 @@ +class OpenapiRailsTypedParameters::TypeGenerator + @config: OpenapiRailsTypedParameters::Configuration + @validator: __todo__ + private attr_accessor config: OpenapiRailsTypedParameters::Configuration + private attr_accessor validator: __todo__ + private def initialize: () -> void + def generate_rbs: () -> String + def operation_to_type_name: (operation: __todo__) -> String + def generate_parameter_definitions: () -> String + def generate_controller_definitions: () -> String + def format: (String rbs) -> String +end diff --git a/sig/typed_parameters.rbs b/sig/typed_parameters.rbs new file mode 100644 index 0000000..3d6ebc4 --- /dev/null +++ b/sig/typed_parameters.rbs @@ -0,0 +1,17 @@ + + +class OpenapiRailsTypedParameters::TypedParameters + type parameter_value = String | Integer | Symbol | bool + type parameters = Hash[Symbol, parameter_value?] + + @request: __todo__ + attr_reader request: __todo__ + private def initialize: (request: __todo__) -> void + def path_params: () -> parameters + def query_params: () -> parameters + def valid?: () -> bool + def to_h: () -> { path_params: parameters, query_params: parameters, body: String?, valid: bool } + def validate!: () -> void + def body: () -> String + def validate: () -> __todo__ +end diff --git a/spec/openapi_rails_typed_parameters_spec.rb b/spec/openapi_rails_typed_parameters_spec.rb index 4d1d0f5..97f0eea 100644 --- a/spec/openapi_rails_typed_parameters_spec.rb +++ b/spec/openapi_rails_typed_parameters_spec.rb @@ -44,4 +44,22 @@ end end end + + describe 'Path parameters' do + context 'with valid query' do + it 'returns response' do + get '/users/123' + expected = { + path_params: { + user_id: 123 + }, + query_params: {}, + body: nil, + valid: true + } + actual = JSON.parse(response.body, symbolize_names: true) + expect(actual).to eq expected + end + end + end end diff --git a/spec/sample_app/Rakefile b/spec/sample_app/Rakefile new file mode 100644 index 0000000..9dc8d07 --- /dev/null +++ b/spec/sample_app/Rakefile @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require_relative 'app' +Rails.application.load_tasks diff --git a/spec/sample_app/app.rb b/spec/sample_app/app.rb index e01f1ed..741ecf0 100644 --- a/spec/sample_app/app.rb +++ b/spec/sample_app/app.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'action_controller/railtie' +require_relative '../../lib/openapi_rails_typed_parameters' class SampleApp < Rails::Application config.active_support.cache_format_version = 7.0 @@ -33,7 +34,13 @@ def index end def show - render json: {} + typed_params = typed_params_for(:show) + typed_params.validate! + render json: typed_params.to_h + rescue OpenapiFirst::RequestInvalidError => e + render json: { + message: e.message + }, status: :bad_request end def create diff --git a/spec/sample_app/lib/tasks/task.rake b/spec/sample_app/lib/tasks/task.rake new file mode 100644 index 0000000..f50fa39 --- /dev/null +++ b/spec/sample_app/lib/tasks/task.rake @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +require 'openapi_rails_typed_parameters/rake_task' +OpenapiRailsTypedParameters::RakeTask.new diff --git a/spec/sample_app/schema.yml b/spec/sample_app/schema.yml index f038e9a..61b4007 100644 --- a/spec/sample_app/schema.yml +++ b/spec/sample_app/schema.yml @@ -24,3 +24,44 @@ paths: required: false schema: type: integer + post: + requestBody: + content: + application/json: + schema: + type: object + properties: + - name: role + required: true + schema: + type: string + enum: [ admin, maintainer ] + /users/{user_id}: + get: + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + /users/{user_id}/articles: + get: + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + /users/{user_id}/articles/{article_id}: + get: + parameters: + - name: user_id + in: path + required: true + schema: + type: integer + - name: article_id + in: path + required: true + schema: + type: integer diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4b8bdf7..ee9b1e5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true require 'openapi_rails_typed_parameters' -require 'sample_app/app' require 'rspec/rails' +require 'sample_app/app' RSpec.configure do |config| # Enable flags like --only-failures and --next-failure diff --git a/spec/type_generator_spec.rb b/spec/type_generator_spec.rb new file mode 100644 index 0000000..4a825fd --- /dev/null +++ b/spec/type_generator_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe OpenapiRailsTypedParameters::TypeGenerator do + describe 'generate_rbs' do + context 'Parameter types' do + it 'generates correct types' do + OpenapiRailsTypedParameters.configure do |_config| + # TODO + end + type_generator = OpenapiRailsTypedParameters::TypeGenerator.new + actual = type_generator.generate_parameter_definitions + expected = <<~RBS + type get_users = { + path_params: { }, + query_params: { + role: String, + minimum: Integer?, + maximum: Integer? + }, + body: __todo__, + valid: bool + } + type post_users = { + path_params: { }, + query_params: { }, + body: __todo__, + valid: bool + } + type get_users_user_id = { + path_params: { user_id: Integer }, + query_params: { }, + body: __todo__, + valid: bool + } + type get_users_user_id_articles = { + path_params: { user_id: Integer }, + query_params: { }, + body: __todo__, + valid: bool + } + type get_users_user_id_articles_article_id = { + path_params: { user_id: Integer, article_id: Integer }, + query_params: { }, + body: __todo__, + valid: bool + } + RBS + + expect(actual).to eq(type_generator.format(expected)) + end + end + + context 'Controller actions' do + it 'generates correct actions' do + type_generator = OpenapiRailsTypedParameters::TypeGenerator.new + actual = type_generator.generate_controller_definitions + expected = <<~RBS + class UsersController + def self.typed_params_for: (:create) -> post_users + | (:index) -> get_users + | (:show) -> get_users_user_id + end + RBS + + expect(actual).to eq(type_generator.format(expected)) + end + end + end +end