From 20792117c527f39058a40c41ff119a19c64a3f5e Mon Sep 17 00:00:00 2001 From: Beth Skurrie Date: Tue, 13 Apr 2021 15:10:25 +1000 Subject: [PATCH] chore: add script to generate scaffolding for a new resource (#415) * chore: add script to generate scaffolding for a new resource * chore: remove puts * chore(scaffolding): add more * chore: more scaffolding * chore: update scaffolding --- scaffolding/README.md | 23 ++ scaffolding/run.rb | 237 +++++++++++++++++++ scaffolding/templates/decorator.rb.erb | 13 + scaffolding/templates/decorator_spec.erb.rb | 0 scaffolding/templates/migration.erb | 12 + scaffolding/templates/model.erb | 14 ++ scaffolding/templates/repository.rb.erb | 18 ++ scaffolding/templates/repository_spec.rb.erb | 9 + scaffolding/templates/resource.erb | 46 ++++ scaffolding/templates/resource_spec.rb.erb | 78 ++++++ scaffolding/templates/service.rb.erb | 22 ++ scaffolding/templates/service_spec.rb.erb | 9 + 12 files changed, 481 insertions(+) create mode 100644 scaffolding/README.md create mode 100644 scaffolding/run.rb create mode 100644 scaffolding/templates/decorator.rb.erb create mode 100644 scaffolding/templates/decorator_spec.erb.rb create mode 100644 scaffolding/templates/migration.erb create mode 100644 scaffolding/templates/model.erb create mode 100644 scaffolding/templates/repository.rb.erb create mode 100644 scaffolding/templates/repository_spec.rb.erb create mode 100644 scaffolding/templates/resource.erb create mode 100644 scaffolding/templates/resource_spec.rb.erb create mode 100644 scaffolding/templates/service.rb.erb create mode 100644 scaffolding/templates/service_spec.rb.erb diff --git a/scaffolding/README.md b/scaffolding/README.md new file mode 100644 index 000000000..c5803351e --- /dev/null +++ b/scaffolding/README.md @@ -0,0 +1,23 @@ +# Scaffolding + +Generates a new model class and its associated: + + * migration + * resource + * decorator (todo) + * service (todo) + * repository (todo) + * resource spec (todo) + * decorator spec (todo) + * service spec (todo) + * repository spec (todo) + +## Usage + +Set `MODEL_CLASS_FULL_NAME` to the full name of the class, and run: + + ``` +bundle exec ruby scaffolding/run.rb + ``` + +Note that the class name must be in the format X::Y::Z (a class nested inside two modules). diff --git a/scaffolding/run.rb b/scaffolding/run.rb new file mode 100644 index 000000000..1200812d2 --- /dev/null +++ b/scaffolding/run.rb @@ -0,0 +1,237 @@ +require 'pact_broker/string_refinements' +require 'pact_broker/project_root' +require 'date' +require 'erb' +require 'pathname' + +using PactBroker::StringRefinements + +MODEL_CLASS_FULL_NAME = "PactBroker::Foos::Foo" +DRY_RUN = false + +TEMPLATE_DIR = Pathname.new(File.join(__dir__, "templates")) +MIGRATIONS_DIR = PactBroker.project_root.join("db", "migrations") +LIB_DIR = PactBroker.project_root.join("lib") +SPEC_DIR = PactBroker.project_root.join("spec", "lib") + +def model_full_class_name + MODEL_CLASS_FULL_NAME +end + +def today + DateTime.now.strftime('%Y%m%d') +end + +def require_path_prefix + model_top_module.snakecase +end + +def migration_path + MIGRATIONS_DIR.join(today + "_create_#{table_name}_table.rb") +end + +def model_class_name + model_full_class_name.split("::").last +end + +def model_top_module + model_full_class_name.split("::").first +end + +def repository_class_full_name + model_full_class_name.split("::")[0..1].join("::") + "::Repository" +end + +# Resource + +def resource_top_module + model_top_module +end + +def resource_class_name + model_class_name +end + +def resource_class_full_name + "#{resource_top_module}::Api::Resources::#{resource_class_name}" +end + +def resource_url_path + model_class_name_snakecase.gsub("_", "-") + "s" +end + +# Decorator + +def decorator_class_name + model_class_name + "Decorator" +end + +def decorator_full_class_name + "#{resource_top_module}::Api::Decorators::#{resource_class_name}Decorator" +end + +def decorator_instance_name + model_class_name_snakecase + "_decorator" +end + +# Service + +def service_class_full_name + model_full_class_name.split("::")[0..1].join("::") + "::Service" +end + +def service_class_name + service_class_full_name.split("::").last +end + +def model_secondary_module + model_full_class_name.split("::")[1] +end + +def model_instance_name + model_class_name.snakecase +end + +def policy_name + model_secondary_module.snakecase + "::" + model_class_name.snakecase +end + +def service_instance_name + model_class_name.snakecase + "_service" +end + +# Repository + +def repository_class_full_name + model_full_class_name.split("::")[0..1].join("::") + "::Repository" +end + +def repository_class_name + repository_class_full_name.split("::").last +end + +def repository_instance_name + model_class_name.snakecase + "_repository" +end + +# Table + +def table_name + model_class_name.snakecase +end + +def model_class_name_snakecase + model_class_name.snakecase +end + +# File paths + +def migration_template_path + File.join(__dir__, "templates", "migration.erb") +end + +def model_path + LIB_DIR.join(*model_full_class_name.split("::").collect(&:snakecase)).to_s.chomp("/") + ".rb" +end + +def resource_path + LIB_DIR.join(model_top_module.snakecase, "api", "resources", model_class_name_snakecase + ".rb") +end + +def resource_spec_path + resource_path.to_s.gsub(LIB_DIR.to_s, SPEC_DIR.to_s).gsub(".rb", "_spec.rb") +end + +def resource_require_path + Pathname.new(resource_path).relative_path_from(LIB_DIR).to_s.chomp(".rb") +end + +def decorator_path + LIB_DIR.join(model_top_module.snakecase, "api", "decorators", model_class_name_snakecase + "_decorator.rb") +end + +def decorator_require_path + Pathname.new(decorator_path).relative_path_from(LIB_DIR).to_s.chomp(".rb") +end + +def service_path + LIB_DIR.join(*service_class_full_name.split("::").collect(&:snakecase)).to_s.chomp("/") + ".rb" +end + +def service_require_path + Pathname.new(service_path).relative_path_from(LIB_DIR).to_s.chomp(".rb") +end + +def service_spec_path + service_path.to_s.gsub(LIB_DIR.to_s, SPEC_DIR.to_s).gsub(".rb", "_spec.rb") +end + +def repository_path + LIB_DIR.join(*repository_class_full_name.split("::").collect(&:snakecase)).to_s.chomp("/") + ".rb" +end + +def repository_require_path + Pathname.new(repository_path).relative_path_from(LIB_DIR).to_s.chomp(".rb") +end + +def repository_spec_path + repository_path.to_s.gsub(LIB_DIR.to_s, SPEC_DIR.to_s).gsub(".rb", "_spec.rb") +end + +# Generate + +def generate_migration_file + generate_file(migration_template_path, migration_path) +end + +def generate_model_file + generate_file(TEMPLATE_DIR.join("model.erb"), model_path) +end + +def generate_resource_file + generate_file(TEMPLATE_DIR.join("resource.erb"), resource_path) +end + +def generate_resource_spec + generate_file(TEMPLATE_DIR.join("resource_spec.rb.erb"), resource_spec_path) +end + +def generate_decorator_file + generate_file(TEMPLATE_DIR.join("decorator.rb.erb"), decorator_path) +end + +def generate_service_file + generate_file(TEMPLATE_DIR.join("service.rb.erb"), service_path) +end + +def generate_service_spec_file + generate_file(TEMPLATE_DIR.join("service_spec.rb.erb"), service_spec_path) +end + +def generate_repository_file + generate_file(TEMPLATE_DIR.join("repository.rb.erb"), repository_path) +end + +def generate_repository_spec_file + generate_file(TEMPLATE_DIR.join("repository_spec.rb.erb"), repository_spec_path) +end + + +def generate_file(template, destination) + puts "Generating file #{destination}" + file_content = ERB.new(File.read(template)).result(binding).tap { |it| puts it } + if !DRY_RUN + FileUtils.mkdir_p(File.dirname(destination)) + File.open(destination, "w") { |file| file << file_content } + end +end + +generate_migration_file +generate_model_file +generate_resource_file +generate_resource_spec +generate_decorator_file +generate_service_file +generate_service_spec_file +generate_repository_file +generate_repository_spec_file diff --git a/scaffolding/templates/decorator.rb.erb b/scaffolding/templates/decorator.rb.erb new file mode 100644 index 000000000..8e0f2b7da --- /dev/null +++ b/scaffolding/templates/decorator.rb.erb @@ -0,0 +1,13 @@ +require 'pact_broker/api/decorators/base_decorator' + +module <%= resource_top_module %> + module Api + module Decorators + class <%= decorator_class_name %> < BaseDecorator + property :uuid + + include Timestamps + end + end + end +end diff --git a/scaffolding/templates/decorator_spec.erb.rb b/scaffolding/templates/decorator_spec.erb.rb new file mode 100644 index 000000000..e69de29bb diff --git a/scaffolding/templates/migration.erb b/scaffolding/templates/migration.erb new file mode 100644 index 000000000..a77011412 --- /dev/null +++ b/scaffolding/templates/migration.erb @@ -0,0 +1,12 @@ +Sequel.migration do + change do + create_table(:<%= table_name %>, charset: 'utf8') do + primary_key :id + String :uuid, null: false + + DateTime :created_at, null: false + DateTime :updated_at, null: false + index [:uuid], unique: true, name: "<%= table_name %>_uuid_index" + end + end +end diff --git a/scaffolding/templates/model.erb b/scaffolding/templates/model.erb new file mode 100644 index 000000000..745769c85 --- /dev/null +++ b/scaffolding/templates/model.erb @@ -0,0 +1,14 @@ +require 'sequel' +require 'pact_broker/repositories/helpers' + +module <%= model_top_module %> + module <%= model_secondary_module %> + class <%= model_class_name %> < Sequel::Model + plugin :timestamps, update_on_create: true + + dataset_module do + include PactBroker::Repositories::Helpers + end + end + end +end diff --git a/scaffolding/templates/repository.rb.erb b/scaffolding/templates/repository.rb.erb new file mode 100644 index 000000000..0cbfc8d56 --- /dev/null +++ b/scaffolding/templates/repository.rb.erb @@ -0,0 +1,18 @@ +require 'pact_broker/logging' +require 'pact_broker/error' + +module <%= model_top_module %> + module <%= model_secondary_module %> + class <%= repository_class_name %> + include PactBroker::Logging + + def self.find_by_uuid(uuid) + <%= model_class_name %>.where(uuid: uuid).single_record + end + + def self.find_by_uuid!(uuid) + find_by_uuid(uuid) or raise PactBroker::Error.new("<%= model_class_name %> with UUID #{uuid} does not exist") + end + end + end +end diff --git a/scaffolding/templates/repository_spec.rb.erb b/scaffolding/templates/repository_spec.rb.erb new file mode 100644 index 000000000..8c541df47 --- /dev/null +++ b/scaffolding/templates/repository_spec.rb.erb @@ -0,0 +1,9 @@ +require '<%= repository_require_path %>' + +module <%= model_top_module %> + module <%= model_secondary_module %> + describe <%= repository_class_name %> do + + end + end +end diff --git a/scaffolding/templates/resource.erb b/scaffolding/templates/resource.erb new file mode 100644 index 000000000..d35ad1df1 --- /dev/null +++ b/scaffolding/templates/resource.erb @@ -0,0 +1,46 @@ +require 'pact_broker/api/resources/base_resource' +require '<%= decorator_require_path %>' + +module <%= resource_top_module %> + module Api + module Resources + class <%= resource_class_name %> < BaseResource + def content_types_provided + [["application/hal+json", :to_json]] + end + + def allowed_methods + ["GET", "OPTIONS"] + end + + def resource_exists? + !!<%= model_instance_name %> + end + + def to_json + decorator_class(:<%= decorator_instance_name %>).new(<%= model_instance_name %>).to_json(decorator_options) + end + + def policy_name + :<%= policy_name %> + end + + private + + attr_reader :<%= model_instance_name %> + + def <%= model_instance_name %> + @<%= model_instance_name %> ||= <%= service_instance_name %>.find_by_uuid(uuid) + end + + # DELETE THIS!!! It's just here so that the generated test can be run + def <%= service_instance_name %> + end + + def uuid + identifier_from_path[:<%= model_instance_name %>_uuid] + end + end + end + end +end diff --git a/scaffolding/templates/resource_spec.rb.erb b/scaffolding/templates/resource_spec.rb.erb new file mode 100644 index 000000000..2617e2bf0 --- /dev/null +++ b/scaffolding/templates/resource_spec.rb.erb @@ -0,0 +1,78 @@ +require '<%= resource_require_path %>' + +module <%= resource_top_module %> + module Api + module Resources + describe <%= resource_class_name %> do + before do + # Delete this when the route has been added to the API + allow_any_instance_of(described_class).to receive(:application_context).and_return(PactBroker::ApplicationContext.default_application_context) + allow_any_instance_of(described_class).to receive(:<%= service_instance_name %>).and_return(<%= service_instance_name %>) + allow(<%= service_instance_name %>).to receive(:find_by_uuid).and_return(<%= model_instance_name %>) + allow(<%= decorator_full_class_name %>).to receive(:new).and_return(decorator) + end + + let(:<%= model_instance_name %>) { instance_double("<%= model_full_class_name %>") } + let(:parsed_<%= model_instance_name %>) { double("parsed <%= model_instance_name %>") } + let(:<%= service_instance_name %>) { class_double("<%= service_class_full_name %>").as_stubbed_const } + let(:path) { "/<%= resource_url_path %>/#{uuid}" } + let(:uuid) { "12345678" } + let(:rack_headers) do + { + "HTTP_ACCEPT" => "application/hal+json" + } + end + let(:decorator) do + instance_double("<%= decorator_full_class_name %>", + to_json: "response", + from_json: parsed_<%= model_instance_name %> + ) + end + + # Delete this when the route has been added to the API - this is just here so that the generated + # spec can be run to see if it works. + let(:app) do + pact_api = Webmachine::Application.new do |app| + app.routes do + add ["<%= resource_url_path %>", :<%= model_instance_name %>_uuid], <%= resource_class_full_name %>, { resource_name: "<%= model_instance_name %>" } + end + end + pact_api.configure do |config| + config.adapter = :RackMapped + end + + pact_api.adapter + end + + describe "GET" do + subject { get(path, nil, rack_headers) } + + it "attempts to find the <%= model_class_name %>" do + expect(<%= service_class_full_name %>).to receive(:find_by_uuid).with(uuid) + subject + end + + context "when the <%= model_instance_name %> does not exist" do + let(:<%= model_instance_name %>) { nil } + + it { is_expected.to be_a_404_response } + end + + context "when the <%= model_class_name %> exists" do + it "generates a JSON representation of the <%= model_class_name %>" do + expect(<%= decorator_full_class_name %>).to receive(:new).with(<%= model_instance_name %>) + expect(decorator).to receive(:to_json).with(user_options: hash_including(base_url: "http://example.org")) + subject + end + + it { is_expected.to be_a_hal_json_success_response } + + it "includes the JSON representation in the response body" do + expect(subject.body).to eq "response" + end + end + end + end + end + end +end diff --git a/scaffolding/templates/service.rb.erb b/scaffolding/templates/service.rb.erb new file mode 100644 index 000000000..6fcd2c793 --- /dev/null +++ b/scaffolding/templates/service.rb.erb @@ -0,0 +1,22 @@ +require 'pact_broker/logging' +require '<%= require_path_prefix %>/repositories' +require '<%= require_path_prefix %>/services' + +module <%= model_top_module %> + module <%= model_secondary_module %> + class <%= service_class_name %> + extend self + extend <%= model_top_module %>::Repositories + extend <%= model_top_module %>::Services + include PactBroker::Logging + + def self.find_by_uuid(uuid) + <%= repository_instance_name %>.find_by_uuid(uuid) + end + + def self.find_by_uuid!(uuid) + <%= repository_instance_name %>.find_by_uuid!(uuid) + end + end + end +end diff --git a/scaffolding/templates/service_spec.rb.erb b/scaffolding/templates/service_spec.rb.erb new file mode 100644 index 000000000..671505082 --- /dev/null +++ b/scaffolding/templates/service_spec.rb.erb @@ -0,0 +1,9 @@ +require '<%= service_require_path %>' + +module <%= model_top_module %> + module <%= model_secondary_module %> + describe <%= service_class_name %> do + + end + end +end