From 9fb731aa80d2fc06fb27eb625d176d8dbbef1db5 Mon Sep 17 00:00:00 2001 From: lucasocon Date: Thu, 2 Mar 2017 21:49:50 -0300 Subject: [PATCH] Add user login --- .env.development.sample | 7 + .env.test.sample | 7 + Gemfile | 37 ++++ Gemfile.lock | 202 ++++++++++++++++++ Makefile | 27 +++ README.md | 83 +++++++ Rakefile | 28 +++ application/api.rb | 77 +++++++ application/api/users.rb | 65 ++++++ application/api_entities/user.rb | 18 ++ application/api_helpers/api_response.rb | 21 ++ application/api_helpers/auth.rb | 25 +++ application/api_helpers/shared_params.rb | 13 ++ application/config/grape.rb | 11 + application/config/hanami.rb | 23 ++ application/config/sequel.rb | 96 +++++++++ application/config/variables.rb | 27 +++ application/config/yaml/errors.yml | 5 + application/config/yaml/sidekiq.yml | 8 + application/lib/abilities.rb | 17 ++ application/lib/core_ext.rb | 55 +++++ application/lib/io.rb | 15 ++ application/lib/pretty_logger.rb | 95 ++++++++ application/lib/time_formats.rb | 10 + application/lib/user_roles.rb | 78 +++++++ .../migrate/1482815196_add_initial_tables.rb | 14 ++ .../1488320988_add_user_tokens_table.rb | 15 ++ application/models/user.rb | 35 +++ application/models/user_token.rb | 25 +++ application/spec/api/users/get_spec.rb | 16 ++ application/spec/api/users/patch_spec.rb | 42 ++++ application/spec/api/users/post_spec.rb | 41 ++++ application/spec/api/users/put_spec.rb | 43 ++++ application/spec/factories/user.rb | 9 + application/spec/spec_helper.rb | 89 ++++++++ application/tasks/db.rake | 73 +++++++ .../validations/create_user_validation.rb | 12 ++ .../validations/edit_user_validation.rb | 11 + .../reset_password_user_validation.rb | 7 + config.ru | 5 + 40 files changed, 1487 insertions(+) create mode 100755 .env.development.sample create mode 100755 .env.test.sample create mode 100755 Gemfile create mode 100755 Gemfile.lock create mode 100755 Makefile create mode 100755 README.md create mode 100755 Rakefile create mode 100755 application/api.rb create mode 100755 application/api/users.rb create mode 100644 application/api_entities/user.rb create mode 100755 application/api_helpers/api_response.rb create mode 100755 application/api_helpers/auth.rb create mode 100755 application/api_helpers/shared_params.rb create mode 100755 application/config/grape.rb create mode 100755 application/config/hanami.rb create mode 100755 application/config/sequel.rb create mode 100755 application/config/variables.rb create mode 100755 application/config/yaml/errors.yml create mode 100755 application/config/yaml/sidekiq.yml create mode 100755 application/lib/abilities.rb create mode 100755 application/lib/core_ext.rb create mode 100755 application/lib/io.rb create mode 100755 application/lib/pretty_logger.rb create mode 100755 application/lib/time_formats.rb create mode 100755 application/lib/user_roles.rb create mode 100755 application/migrate/1482815196_add_initial_tables.rb create mode 100755 application/migrate/1488320988_add_user_tokens_table.rb create mode 100755 application/models/user.rb create mode 100755 application/models/user_token.rb create mode 100755 application/spec/api/users/get_spec.rb create mode 100644 application/spec/api/users/patch_spec.rb create mode 100644 application/spec/api/users/post_spec.rb create mode 100644 application/spec/api/users/put_spec.rb create mode 100755 application/spec/factories/user.rb create mode 100755 application/spec/spec_helper.rb create mode 100755 application/tasks/db.rake create mode 100644 application/validations/create_user_validation.rb create mode 100644 application/validations/edit_user_validation.rb create mode 100644 application/validations/reset_password_user_validation.rb create mode 100755 config.ru diff --git a/.env.development.sample b/.env.development.sample new file mode 100755 index 0000000..678b0fe --- /dev/null +++ b/.env.development.sample @@ -0,0 +1,7 @@ +RACK_ENV=development +DATABASE_URL=mysql2://root:@127.0.0.1:3306/sample?reconnect=true +SYSTEM_EMAIL=support@sample.com +SITE_URL=http://localhost:3000/ +SMTP_SERVER=mail.example.com +MAIL_SMTP_USER=user@example.com +MAIL_SMTP_PASSWORD=example diff --git a/.env.test.sample b/.env.test.sample new file mode 100755 index 0000000..83d91c5 --- /dev/null +++ b/.env.test.sample @@ -0,0 +1,7 @@ +RACK_ENV=test +DATABASE_URL=mysql2://root:@127.0.0.1:3306/sample_test?reconnect=true +SYSTEM_EMAIL=support@sample.com +SITE_URL=http://localhost:3000/ +SMTP_SERVER=mail.example.com +MAIL_SMTP_USER=user@example.com +MAIL_SMTP_PASSWORD=example diff --git a/Gemfile b/Gemfile new file mode 100755 index 0000000..b654c5a --- /dev/null +++ b/Gemfile @@ -0,0 +1,37 @@ +ruby '2.3.3' + +source 'https://rubygems.org' + +gem 'thin', '1.7.0' +gem 'rack', '1.6.4' +gem 'rerun', '0.11.0' +gem 'rake', '11.2.2' +gem 'grape', '0.18.0' # api routing +gem 'grape-batch', '2.3.0' +gem 'grape-entity', '0.6.0' # define api input and output +gem 'grape-swagger', '0.25.1' # document api +gem 'grape-swagger-entity', '0.1.5' # parse entities in api +gem 'rack-indifferent', '1.1' # makes param keys symbols +gem 'mysql2', '0.4.5' +gem 'sequel', '4.40.0' +gem 'mail', '2.6.4' +gem 'uuidtools', ' 2.1.5' +gem 'hanami-validations', '0.6.0' # form validation +gem 'dry-validation', '0.10.4' # validation methods for reform +gem 'ability_list', '0.0.4' +gem 'activesupport', '5.0.0' + +group :development, :test do + gem 'awesome_print', '1.7.0' + gem 'pry', '0.10.4' +end + +group :test do + gem 'webmock', '2.1.0' + gem 'vcr', '3.0.3' + gem 'database_cleaner', '1.5.3' + gem 'factory_girl', '4.7.0' + gem 'faker', '1.6.6' + gem 'rack-test', '0.6.3' + gem 'rspec', '3.5.0' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100755 index 0000000..ad129a5 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,202 @@ +GEM + remote: https://rubygems.org/ + specs: + ability_list (0.0.4) + activesupport (5.0.0) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (~> 0.7) + minitest (~> 5.1) + tzinfo (~> 1.1) + addressable (2.5.0) + public_suffix (~> 2.0, >= 2.0.2) + awesome_print (1.7.0) + axiom-types (0.1.1) + descendants_tracker (~> 0.0.4) + ice_nine (~> 0.11.0) + thread_safe (~> 0.3, >= 0.3.1) + builder (3.2.2) + coderay (1.1.1) + coercible (1.0.0) + descendants_tracker (~> 0.0.1) + concurrent-ruby (1.0.4) + crack (0.4.3) + safe_yaml (~> 1.0.0) + daemons (1.2.4) + database_cleaner (1.5.3) + descendants_tracker (0.0.4) + thread_safe (~> 0.3, >= 0.3.1) + diff-lcs (1.2.5) + dry-configurable (0.5.0) + concurrent-ruby (~> 1.0) + dry-container (0.6.0) + concurrent-ruby (~> 1.0) + dry-configurable (~> 0.1, >= 0.1.3) + dry-core (0.2.1) + concurrent-ruby (~> 1.0) + dry-equalizer (0.2.0) + dry-logic (0.4.0) + dry-container (~> 0.2, >= 0.2.6) + dry-core (~> 0.1) + dry-equalizer (~> 0.2) + dry-types (0.9.3) + concurrent-ruby (~> 1.0) + dry-configurable (~> 0.1) + dry-container (~> 0.3) + dry-core (~> 0.2, >= 0.2.1) + dry-equalizer (~> 0.2) + dry-logic (~> 0.4, >= 0.4.0) + inflecto (~> 0.0.0, >= 0.0.2) + dry-validation (0.10.4) + concurrent-ruby (~> 1.0) + dry-configurable (~> 0.1, >= 0.1.3) + dry-container (~> 0.2, >= 0.2.8) + dry-core (~> 0.2, >= 0.2.1) + dry-equalizer (~> 0.2) + dry-logic (~> 0.4, >= 0.4.0) + dry-types (~> 0.9, >= 0.9.0) + equalizer (0.0.11) + eventmachine (1.2.1) + factory_girl (4.7.0) + activesupport (>= 3.0.0) + faker (1.6.6) + i18n (~> 0.5) + ffi (1.9.14) + grape (0.18.0) + activesupport + builder + hashie (>= 2.1.0) + multi_json (>= 1.3.2) + multi_xml (>= 0.5.2) + mustermann-grape (~> 0.4.0) + rack (>= 1.3.0) + rack-accept + virtus (>= 1.0.0) + grape-batch (2.3.0) + multi_json (>= 1.0) + grape-entity (0.6.0) + activesupport + multi_json (>= 1.3.2) + grape-swagger (0.25.1) + grape (>= 0.12.0) + grape-swagger-entity (0.1.5) + grape-entity + grape-swagger (>= 0.20.4) + hanami-utils (0.9.2) + hanami-validations (0.6.0) + dry-validation (~> 0.9) + hanami-utils (~> 0.8) + hashdiff (0.3.1) + hashie (3.4.6) + i18n (0.7.0) + ice_nine (0.11.2) + inflecto (0.0.2) + listen (3.1.5) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + ruby_dep (~> 1.2) + mail (2.6.4) + mime-types (>= 1.16, < 4) + method_source (0.8.2) + mime-types (3.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2016.0521) + minitest (5.10.1) + multi_json (1.12.1) + multi_xml (0.6.0) + mustermann (0.4.0) + tool (~> 0.2) + mustermann-grape (0.4.0) + mustermann (= 0.4.0) + mysql2 (0.4.5) + pry (0.10.4) + coderay (~> 1.1.0) + method_source (~> 0.8.1) + slop (~> 3.4) + public_suffix (2.0.4) + rack (1.6.4) + rack-accept (0.4.5) + rack (>= 0.4) + rack-indifferent (1.1.0) + rack (>= 1.5) + rack-test (0.6.3) + rack (>= 1.0) + rake (11.2.2) + rb-fsevent (0.9.8) + rb-inotify (0.9.7) + ffi (>= 0.5.0) + rerun (0.11.0) + listen (~> 3.0) + rspec (3.5.0) + rspec-core (~> 3.5.0) + rspec-expectations (~> 3.5.0) + rspec-mocks (~> 3.5.0) + rspec-core (3.5.4) + rspec-support (~> 3.5.0) + rspec-expectations (3.5.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.5.0) + rspec-mocks (3.5.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.5.0) + rspec-support (3.5.0) + ruby_dep (1.5.0) + safe_yaml (1.0.4) + sequel (4.40.0) + slop (3.6.0) + thin (1.7.0) + daemons (~> 1.0, >= 1.0.9) + eventmachine (~> 1.0, >= 1.0.4) + rack (>= 1, < 3) + thread_safe (0.3.5) + tool (0.2.3) + tzinfo (1.2.2) + thread_safe (~> 0.1) + uuidtools (2.1.5) + vcr (3.0.3) + virtus (1.0.5) + axiom-types (~> 0.1) + coercible (~> 1.0) + descendants_tracker (~> 0.0, >= 0.0.3) + equalizer (~> 0.0, >= 0.0.9) + webmock (2.1.0) + addressable (>= 2.3.6) + crack (>= 0.3.2) + hashdiff + +PLATFORMS + ruby + +DEPENDENCIES + ability_list (= 0.0.4) + activesupport (= 5.0.0) + awesome_print (= 1.7.0) + database_cleaner (= 1.5.3) + dry-validation (= 0.10.4) + factory_girl (= 4.7.0) + faker (= 1.6.6) + grape (= 0.18.0) + grape-batch (= 2.3.0) + grape-entity (= 0.6.0) + grape-swagger (= 0.25.1) + grape-swagger-entity (= 0.1.5) + hanami-validations (= 0.6.0) + mail (= 2.6.4) + mysql2 (= 0.4.5) + pry (= 0.10.4) + rack (= 1.6.4) + rack-indifferent (= 1.1) + rack-test (= 0.6.3) + rake (= 11.2.2) + rerun (= 0.11.0) + rspec (= 3.5.0) + sequel (= 4.40.0) + thin (= 1.7.0) + uuidtools (= 2.1.5) + vcr (= 3.0.3) + webmock (= 2.1.0) + +RUBY VERSION + ruby 2.3.3p222 + +BUNDLED WITH + 1.13.6 diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..3f7042b --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: install run run_production console test + +ifeq (test,$(firstword $(MAKECMDGOALS))) + # use the rest as arguments for "test" + RUN_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) + # ...and turn them into do-nothing targets + $(eval $(RUN_ARGS):;@:) +endif + +install: + bundle config path .bundle + bundle + touch .env.test + touch .env.development + +run: + bundle exec rerun -b --pattern '{Gemfile,Gemfile.lock,.gems,.bundle,.env*,config.ru,**/*.{rb,ru,yml}}' -- thin start --port=3000 --threaded + +console: + bundle exec pry -r ./application/api + +test: +ifeq ($(RUN_ARGS),) + bundle exec rake spec +else + bundle exec rake spec SPEC=$(RUN_ARGS) +endif diff --git a/README.md b/README.md new file mode 100755 index 0000000..8680857 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Sample Ruby API + +## Sample Objectives + +You will be creating a simple user management API using the tools described below. Already provided is a very basic ruby application which you will need to build the following endpoints for: + +### Features that you need to implement +1. POST /users - Create a new user. It should accept the following fields: first name, last name, email, password, date of birth. Date of Birth is the only optional field. Upon successful creation it should send an email to their address confirming the new account (add the mail gem or any other mailer you prefer) +2. PUT /users/:id - Update a user. Add authentication so only the current user can update their own account. There is already a user ability created for this (see `application/lib/abilities.rb`). +3. PATCH /users/:id/reset_password - Update a user password. It should accept the following fields: new_password, confirm_password. It should also send them an email letting them know their password has been updated. + +### Coding guidelines for each feature +1. All endpoints should have their input and response formats defined using Grape Entities (see URL for library below). +2. Input data should be validated using Hanami forms. +3. Each feature must have a spec testing different scenarios and possible failures. Check that the data is being returned properly. +4. Use grape-swagger to automatically generate swagger docs. +5. Use background processing where applicable, like mailers (ie. Sidekiq gem). Make sure to have independent spec tests for these jobs. +6. Come up with your own authentication pattern in `application/api_helpers/auth.rb` (examples include: ruby-jwt, generic oauth, signature headers) + + +## Installation Process: + +1. Install RVM (https://rvm.io/rvm/install) +2. Run `rvm install 2.3.3` +3. Run `gem install bundler` +4. Clone repository +5. `cd ruby-api-example` +6. Run `bundle install` +7. Duplicate .env.development.sample file and rename to .env.development +8. Enter correct env values for .env.development +9. Repeat steps 7 and 8 for env.test +10. To start ruby server, run `make run` +11. The API will now be accessible at http://localhost:3000/api/v1.0/users + +**Note:** Be sure to set database url for env.test to your test database and not your development database. Tests will truncate all tables in the test database before running! + +## Libraries + +### Routing + +Grape: https://github.com/ruby-grape/grape + +Grape Entity: https://github.com/ruby-grape/grape-entity + +Grape Swagger: https://github.com/ruby-grape/grape-swagger + +### Database / Models + +Sequel: http://sequel.jeremyevans.net/ + +### Forms + +Hanami: https://github.com/hanami/validations + +Uses dry validation as the syntax to validate inputs. Suggested reading: http://dry-rb.org/gems/dry-validation/ + +### Testing + +Rspec: http://www.relishapp.com/rspec/rspec-core/docs + +Factory Girl: https://github.com/thoughtbot/factory_girl + +Faker: https://github.com/stympy/faker + +## Migrations + +To create a migration: `bundle exec rake "db:migration[my_migration]"` + +To code the migration: go to `application/migrate/XXXXXX_my_migration.rb` -- instructions here: https://github.com/jeremyevans/sequel/blob/master/doc/migration.rdoc + +To apply the migration: `bundle exec rake db:migrate` + +To apply the migration to your test database: `RACK_ENV=test bundle exec rake db:migrate` + +## Running Tests + +Run your tests using: + +`make test` + +Run a specific test by providing the path to the file: + +`bundle exec rspec ./application/spec/users_spec.rb` diff --git a/Rakefile b/Rakefile new file mode 100755 index 0000000..0ae40d8 --- /dev/null +++ b/Rakefile @@ -0,0 +1,28 @@ +$:.unshift(File.expand_path("./application")) +current_task = Rake.application.top_level_tasks.first + +ENV['RACK_ENV'] ||= current_task['spec'] ? 'test' : 'development' + +require 'bundler' +require 'bundler/setup' + +# Database tasks should not need the whole app +if current_task['db:'] + require 'sequel' + require 'config/variables' + require 'config/sequel' + SEQUEL_DB = Api::SEQUEL_DB +# Testing does not need the app either as it will create its own app +elsif !current_task['spec'] + require 'api' +end + +if current_task['spec'] + require 'rspec/core/rake_task' + RSpec::Core::RakeTask.new(:spec) do |t| + t.pattern = Dir.glob('application/spec/**/*_spec.rb') + t.ruby_opts = "-I#{File.expand_path("./application/spec")}" + end +else + Dir["./application/tasks/**/*.rake"].each { |rb| import rb } +end diff --git a/application/api.rb b/application/api.rb new file mode 100755 index 0000000..97fea37 --- /dev/null +++ b/application/api.rb @@ -0,0 +1,77 @@ +Encoding.default_external = 'UTF-8' + +$LOAD_PATH.unshift(File.expand_path('./application')) + +# Include critical gems +require 'config/variables' + +if %w(development test).include?(RACK_ENV) + require 'pry' + require 'awesome_print' +end + +require 'bundler' +Bundler.setup :default, RACK_ENV +require 'rack/indifferent' +require 'grape' +require 'grape/batch' +# Initialize the application so we can add all our components to it +class Api < Grape::API; end + +# Include all config files +require 'config/sequel' +require 'config/hanami' +require 'config/grape' + +# require some global libs +require 'lib/core_ext' +require 'lib/time_formats' +require 'lib/io' + +# load active support helpers +require 'active_support' +require 'active_support/core_ext' +require 'mail' + +# require all models +Dir['./application/models/*.rb'].each { |rb| require rb } +Dir['./application/validations/*.rb'].each { |rb| require rb } + +Dir['./application/api_helpers/**/*.rb'].each { |rb| require rb } +class Api < Grape::API + version 'v1.0', using: :path + content_type :json, 'application/json' + default_format :json + prefix :api + rescue_from Grape::Exceptions::ValidationErrors do |e| + ret = { error_type: 'validation', errors: {} } + e.each do |x, err| + ret[:errors][x[0]] ||= [] + ret[:errors][x[0]] << err.message + end + error! ret, 400 + end + + helpers SharedParams + helpers ApiResponse + include Auth + + Dir['./application/api_entities/**/*.rb'].each { |rb| require rb } + Dir['./application/api/**/*.rb'].each { |rb| require rb } + + add_swagger_documentation \ + mount_path: '/docs' + +Mail.defaults do + delivery_method :smtp, { + address: SMTP_SERVER, + port: 465, + user_name: MAIL_SMTP_USER , + password: MAIL_SMTP_PASSWORD, + authentication: :login, + ssl: true, + tls: true, + enable_starttls_auto: true + } +end +end diff --git a/application/api/users.rb b/application/api/users.rb new file mode 100755 index 0000000..64f2070 --- /dev/null +++ b/application/api/users.rb @@ -0,0 +1,65 @@ +class Api + resource :users do + params do + includes :basic_search + end + get do + users = SEQUEL_DB[:users].all + { + data: users + } + end + + post do + result = CreateUserValidation.new(params).validate + if result.success? && user = Models::User.create(result.output) + mail = ::Mail.new do + from SYSTEM_EMAIL + to user.email + subject 'Your account has been created successfully' + body 'Need a nice body' + end.deliver! + Entities::User.represent(user) + else + error!(result.messages, 400) + end + end + + put ':id' do + authenticate! + user = Models::User[params[:id]] + + return error!('Unauthorized', 401) unless current_user.can?(:edit, user) + + result = EditUserValidation.new(params).validate + if result.success? + user.update(result.output) + Entities::User.represent(user) + else + error!(result.messages, 400) + end + end + + patch ':id/reset_password' do + authenticate! + user = Models::User[params[:id]] + + return error!('Unauthorized', 401) unless current_user.can?(:edit, user) + + result = ResetPasswordUserValidation.new(params).validate + + if result.success? + user.update(password: result.output[:new_password]) + mail = ::Mail.new do + from SYSTEM_EMAIL + to user.email + subject 'Your password has been reset successfully' + body 'Need a nice body' + end.deliver! + Entities::User.represent(user) + else + error!(result.messages, 400) + end + end + end +end diff --git a/application/api_entities/user.rb b/application/api_entities/user.rb new file mode 100644 index 0000000..eb11447 --- /dev/null +++ b/application/api_entities/user.rb @@ -0,0 +1,18 @@ +class Api + module Entities + class User < Grape::Entity + format_with(:iso_timestamp) { |dt| dt.iso8601 } + + expose :id + expose :first_name + expose :last_name + expose :email + + with_options(format_with: :iso_timestamp) do + expose :born_on, if: :born_on + expose :created_at + expose :updated_at + end + end + end +end diff --git a/application/api_helpers/api_response.rb b/application/api_helpers/api_response.rb new file mode 100755 index 0000000..4613eca --- /dev/null +++ b/application/api_helpers/api_response.rb @@ -0,0 +1,21 @@ +class Api + module ApiResponse + extend Grape::API::Helpers + + def api_response response + if response.key? :error_type + case response[:error_type].to_sym + when :not_found + status 404 + when :forbidden + status 403 + else + status 400 + end + else + status 200 + end + response + end + end +end diff --git a/application/api_helpers/auth.rb b/application/api_helpers/auth.rb new file mode 100755 index 0000000..777c804 --- /dev/null +++ b/application/api_helpers/auth.rb @@ -0,0 +1,25 @@ +class Api + module Auth + extend ActiveSupport::Concern + + included do |base| + helpers HelperMethods + end + + module HelperMethods + def authenticate! + error!('Unauthorized. Invalid or expired token.', 401) unless set_current_user + end + + private + def set_current_user + token = Api::Models::UserToken.first(access_token: headers["Token"]) + if token && !token.expired? + Api.class_variable_set(:@@current_user, Api::Models::User[token.user_id]) + else + false + end + end + end + end +end diff --git a/application/api_helpers/shared_params.rb b/application/api_helpers/shared_params.rb new file mode 100755 index 0000000..2b123c8 --- /dev/null +++ b/application/api_helpers/shared_params.rb @@ -0,0 +1,13 @@ +class Api + module SharedParams + extend Grape::API::Helpers + + params :basic_search do + optional :query, type: String + optional :id, types: [Integer, Array[Integer]] + optional :page, type: Integer, default: 1 + optional :per_page, type: Integer, default: 20 + optional :order, type: String + end + end +end diff --git a/application/config/grape.rb b/application/config/grape.rb new file mode 100755 index 0000000..822837f --- /dev/null +++ b/application/config/grape.rb @@ -0,0 +1,11 @@ +require 'grape-swagger' +require 'grape-entity' +require 'grape-swagger-entity' + +Grape::Batch.configure do |config| + config.limit = 10 + config.path = '/api/batch' + config.formatter = Grape::Batch::Response + config.logger = Logger.new(STDOUT) + config.session_proc = Proc.new { } +end diff --git a/application/config/hanami.rb b/application/config/hanami.rb new file mode 100755 index 0000000..6d2aaf0 --- /dev/null +++ b/application/config/hanami.rb @@ -0,0 +1,23 @@ +# Replacement for reform +require 'hanami/validations' +require 'hanami/validations/form' + +module FormPredicates + include Hanami::Validations::Predicates + + self.messages_path = 'application/config/yaml/errors.yml' + + predicate(:phone?) do |current| + current.to_s.match(/\A\d{10}\z/i) + end + + predicate(:email?) do |current| + current.match(/\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i) + end + + predicate(:datetime_str?) do |current| + # Format: YYYY-MM-DD HH:MM:SS TZ - ex: 2016-01-01 02:03:04 -0800 + # Timezone is optional + current.match(/^\d{4}-\d{2}-\d{2} \d{1,2}\:\d{1,2}\:\d{1,2}( \-\d{4})?$/) + end +end diff --git a/application/config/sequel.rb b/application/config/sequel.rb new file mode 100755 index 0000000..0d73883 --- /dev/null +++ b/application/config/sequel.rb @@ -0,0 +1,96 @@ +require 'sequel' + +Sequel.database_timezone = :local + +# basic plugins +Sequel::Model.plugin :schema +Sequel::Model.plugin :validation_helpers +Sequel::Model.plugin :nested_attributes +Sequel::Model.plugin :boolean_readers +Sequel::Model.plugin :table_select +Sequel::Model.plugin :dirty +Sequel::Model.plugin :string_stripper +# This breaks nested_attributes plugin if you're doing a create: +# Sequel::Model.plugin :auto_validations + +# Force all strings to be UTF8 encoded in a all model subclasses +Sequel::Model.plugin :force_encoding, 'UTF-8' + +Sequel::Model.plugin :input_transformer +Sequel::Model.add_input_transformer(:to_nil){|v| v.is_a?(String) && v == '' ? nil : v} + +# Auto-manage created_at/updated_at fields +Sequel::Model.plugin :timestamps, update_on_create: true + +Sequel.extension :connection_validator +Sequel.extension :migration +Sequel.extension :core_extensions +Sequel.extension :pagination + +# custom validation methods +class Sequel::Model + def before_create + if columns.include?(:updater_id) && columns.include?(:creator_id) + self.updater_id = creator_id + end + super + end + + def validates_unchangeable(atts) + atts = atts.kind_of?(Array) ? atts : [atts] + atts.each do |col| + col_sym = col.to_sym + errors.add(col_sym, 'cannot be changed') if column_changed?(col_sym) && column_changes[col_sym][0].present? + end + end +end + +module Sequel + class Dataset + %i`all first`.each do |meth| + alias_method :"original_#{meth}", meth.to_sym + define_method meth do |*args, &block| + type = args.any?? args.pop : nil + args << type if type && type != :nested + + if meth == :first && args.any? + data = send("original_#{meth}", *args, &block) + else + data = send("original_#{meth}", &block) + end + + if type == :nested + data.is_a?(Array) ? + data.map{ |x| _format_nested(x) } : + _format_nested(data) + else + data + end + end + end + + private + + def _format_nested data + ret = {} + data.each do |k, v| + kparts = k.to_s.split '__' + ref = ret + last_key = kparts.pop + kparts.each { |x| ref = (ref[x.to_sym] ||= {}) } + ref[last_key.to_sym] = v + end + ret + end + + end +end + +class Api + SEQUEL_DB = Sequel.connect(DATABASE_URL, connect_timeout: 5, read_timeout: 8, write_timeout: 5) unless defined? SEQUEL_DB + + if RACK_ENV == 'development' || ENV['DEBUG_SQL'] + require 'lib/pretty_logger' + SEQUEL_DB.loggers << PrettyLogger.logger + end +end diff --git a/application/config/variables.rb b/application/config/variables.rb new file mode 100755 index 0000000..b280f0d --- /dev/null +++ b/application/config/variables.rb @@ -0,0 +1,27 @@ +# Default variables +APP_ROOT = File.expand_path '../../', __FILE__ unless defined?(APP_ROOT) +RACK_ENV = ENV.fetch('RACK_ENV') { 'development' }.freeze +LATEST_API_VERSION = 1.0.freeze + +# Load environment variables +%W{.env .env.#{RACK_ENV}}.each do |file| + File.foreach file do |line| + key, value = line.split '=', 2; ENV[key] = value.gsub('\n', '').strip + end if File.file? file +end + +ENV['SYSTEM_USER_ID'] ||= '1' +ENV['SUPPORT_PHONE_NUMBER'] ||= '(866) 464-2157' +ENV['SUPPORT_EMAIL'] ||= 'support@test.com' +ENV['INTERNAL_EMAIL'] ||= 'internal@test.com' + +SYSTEM_USER_ID = ENV.fetch('SYSTEM_USER_ID').to_i.freeze +SUPPORT_PHONE_NUMBER = ENV.fetch('SUPPORT_PHONE_NUMBER').freeze +SUPPORT_EMAIL = ENV.fetch('SUPPORT_EMAIL').freeze +INTERNAL_EMAIL = ENV.fetch('INTERNAL_EMAIL').freeze +DATABASE_URL = ENV.fetch('DATABASE_URL').freeze +SMTP_SERVER = ENV.fetch('SMTP_SERVER').freeze +MAIL_SMTP_USER = ENV.fetch('MAIL_SMTP_USER').freeze +MAIL_SMTP_PASSWORD = ENV.fetch('MAIL_SMTP_PASSWORD').freeze +SYSTEM_EMAIL = ENV.fetch('SYSTEM_EMAIL').freeze +SITE_URL = ENV.fetch('SITE_URL').freeze diff --git a/application/config/yaml/errors.yml b/application/config/yaml/errors.yml new file mode 100755 index 0000000..4128a88 --- /dev/null +++ b/application/config/yaml/errors.yml @@ -0,0 +1,5 @@ +en: + errors: + email?: must be an email + datetime_str?: must be in format YYYY-MM-DD HH:MM:SS + phone?: must be valid format (ex - 8005556262) diff --git a/application/config/yaml/sidekiq.yml b/application/config/yaml/sidekiq.yml new file mode 100755 index 0000000..071bd80 --- /dev/null +++ b/application/config/yaml/sidekiq.yml @@ -0,0 +1,8 @@ +:concurrency: 20 +:queues: + - [priority, 7] + - [default, 5] + - [seldom, 3] +staging: + :concurrency: 10 +:require: ./application/api.rb diff --git a/application/lib/abilities.rb b/application/lib/abilities.rb new file mode 100755 index 0000000..2556055 --- /dev/null +++ b/application/lib/abilities.rb @@ -0,0 +1,17 @@ +require 'ability_list' + +class Api + class Abilities < AbilityList + def initialize(user) + # call the appointment permissions + user_permissions(user) + end + + def user_permissions(user) + # Only current user can edit themselves + can :edit, Models::User do |check_user| + next true if user.id == check_user.id + end + end + end +end diff --git a/application/lib/core_ext.rb b/application/lib/core_ext.rb new file mode 100755 index 0000000..3c4f771 --- /dev/null +++ b/application/lib/core_ext.rb @@ -0,0 +1,55 @@ +class Numeric + # Utility function for numbers to easily format in US Monetary + # Usage: my_var.monetize + def monetize + ('%.2f' % (self)).gsub(/(\d)(?=(\d{3})+(\.\d*)?$)/, '\1,') + end + def add_commas + self.to_s.gsub(/(\d)(?=(\d{3})+(\.\d*)?$)/, '\1,') + end +end + +class String + def format_phone(ext = false) + self.gsub(/(\d{3})(\d{3})(\d{4})/, '(\1) \2-\3') + (ext.blank? ? '' : ' x' + ext) + end + + def valid_email? + (self =~ /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i) + end +end + +class Date + def standard_date + self.strftime('%m/%d/%Y') + end +end + +class Time + # Utility function to standardize date display for Time objects + # Usage: vehicle.created_at.standard_date + def standard_date + self.strftime('%m/%d/%Y') + end + + # Utility function to standardize date+time display for Time objects + # Usage: vehicle.created_at.standard_datetime + def standard_datetime style='default' + case style + # ISO 8601 date format for ECMA standard. Some browsers have difficulty + # parsing various date format strings, so including a date parser method + # in common.js which handles this format in particular (most modern browsers + # can interpret this format) + when 'ecma' + self.strftime('%Y-%m-%dT%H:%M:%S%:z') + when 'short' + self.strftime('%m/%d/%Y %I:%M:%S %p %Z') + # No seconds, eg: 27/04/2015 10:54am ART + when 'compact' + self.strftime('%m/%d/%Y %I:%M%P %Z') + # Default is human readable format + else + self.strftime('%A, %B %d, %Y %I:%M %p %Z') + end + end +end diff --git a/application/lib/io.rb b/application/lib/io.rb new file mode 100755 index 0000000..1714891 --- /dev/null +++ b/application/lib/io.rb @@ -0,0 +1,15 @@ +require 'open-uri' + +module Helpers + class IO + def self.copy_remote_to_temp remote_uri, temp_name = 'templocal' + remote_file = open(remote_uri) + file = Tempfile.new(temp_name) + # Tempfile will assume text file unless we switch to binary + file.binmode + file << remote_file.read + file.rewind + file + end + end +end diff --git a/application/lib/pretty_logger.rb b/application/lib/pretty_logger.rb new file mode 100755 index 0000000..025beda --- /dev/null +++ b/application/lib/pretty_logger.rb @@ -0,0 +1,95 @@ +require 'logger' + +class PrettyLogger + + # middleware to insert a PrettyLogger into rack.logger + class Middleware + def initialize(app, opts={}) + @app = app + @logger = PrettyLogger.new opts + end + def call(env) + env['rack.logger'] = @logger + status, header, body = @app.call(env) + [status, header, body] + end + end + + Config = { + format_datetime: "%d/%b/%Y %H:%M:%S", + level: Logger::DEBUG, + stream: STDERR + } + + Levels = { + :fatal => 4, + :error => 3, + :warn => 2, + :info => 1, + :debug => 0, + :devel => -1, + } + + attr_reader :logger + + def initialize(opts = {}) + opts = Config.merge(opts) + @stream = opts[:stream] + @level = opts[:level] + @format_datetime = opts[:format_datetime] + + @logger = Logger.new(@stream) + @logger.level = @level + @logger.formatter = proc do |severity, datetime, progname, msg| + level = stylized_level(severity) + "#{datetime.strftime(@format_datetime).brown} #{level} #{msg}\n" + end + end + + def debug(msg) logger.debug(msg) end + def info(msg) logger.info(msg) end + def warn(msg) logger.warn(msg) end + def error(msg) logger.error(msg) end + def fatal(msg) logger.fatal(msg) end + + def stylized_level(level) + str = '%5.5s' % level + str = case Levels[level.downcase.to_sym] + when Logger::DEBUG then str.cyan + when Logger::INFO then str.green + when Logger::WARN then str.brown + when Logger::ERROR then str.red + when Logger::FATAL then str.brown.bold.bg_red + else str.gray + end + end + + def self.logger + @_logger ||= self.new + end + def self.logger=(logger) + @_logger = logger + end + +end + +class String + def black; "\033[30m#{self}\033[0m" end + def red; "\033[31m#{self}\033[0m" end + def green; "\033[32m#{self}\033[0m" end + def brown; "\033[33m#{self}\033[0m" end + def blue; "\033[34m#{self}\033[0m" end + def magenta; "\033[35m#{self}\033[0m" end + def cyan; "\033[36m#{self}\033[0m" end + def gray; "\033[37m#{self}\033[0m" end + def bg_black; "\033[40m#{self}\033[0m" end + def bg_red; "\033[41m#{self}\033[0m" end + def bg_green; "\033[42m#{self}\033[0m" end + def bg_brown; "\033[43m#{self}\033[0m" end + def bg_blue; "\033[44m#{self}\033[0m" end + def bg_magenta; "\033[45m#{self}\033[0m" end + def bg_cyan; "\033[46m#{self}\033[0m" end + def bg_gray; "\033[47m#{self}\033[0m" end + def bold; "\033[1m#{self}\033[22m" end + def reverse_color; "\033[7m#{self}\033[27m" end +end diff --git a/application/lib/time_formats.rb b/application/lib/time_formats.rb new file mode 100755 index 0000000..b718fec --- /dev/null +++ b/application/lib/time_formats.rb @@ -0,0 +1,10 @@ +Date::DATE_FORMATS[:date] = '%m/%d/%Y' +Date::DATE_FORMATS[:date_sql] = '%Y-%m-%d' + +Time::DATE_FORMATS[:date] = '%m/%d/%Y' +Time::DATE_FORMATS[:date_sql] = '%Y-%m-%d' +Time::DATE_FORMATS[:date_ymd] = '%Y%m%d' +Time::DATE_FORMATS[:datetime] = '%m/%d/%Y %l:%M %P %Z' +Time::DATE_FORMATS[:datetime_no_timezone] = '%m/%d/%Y %l:%M %P' +Time::DATE_FORMATS[:time] = '%-l:%M%P' +Time::DATE_FORMATS[:time_upcase] = '%-l:%M %p' diff --git a/application/lib/user_roles.rb b/application/lib/user_roles.rb new file mode 100755 index 0000000..9cab960 --- /dev/null +++ b/application/lib/user_roles.rb @@ -0,0 +1,78 @@ +class ApiHelpers + module UserRoles + ROLES = [ + :rep, + :office_manager, + :office_staff, + :doctor, + :system, + :admin + ].freeze + + GROUPS = [ + :office_user, + :premium_rep, + :limited_rep, + :trial_rep + ].freeze + + def role + ROLES.each do |role| + return role if _check_role(role) + end + raise 'Role not found' + end + + def groups + ar = [] + GROUPS.each do |group| + ar << group if _check_group(group) + end + ar + end + + def in_role?(*roles) + roles.map!(&:to_sym).include? role + end + + def in_group?(group) + groups.include? group.to_sym + end + + private + + def _check_role(role) + case role.to_sym + when :rep + self.Type == 'REP' + when :office_manager + self.Type == 'OFFICE MANAGER' + when :office_staff + self.Type == 'NON-MANAGER' + when :doctor + self.Type == 'DOCTOR' + when :system + self.Type == 'SYSTEM' + when :admin + self.Type == 'ADMIN' + else + false + end + end + + def _check_group(group) + case group.to_sym + when :office_user + in_role? :office_manager, :office_staff, :doctor + when :premium_rep + role == :rep && self.Membership_Type == 'Premium' + when :trial_rep + role == :rep && self.Membership_Type == 'Trial' + when :limited_rep + role == :rep && self.Membership_Type == 'Limited' + else + false + end + end + end +end diff --git a/application/migrate/1482815196_add_initial_tables.rb b/application/migrate/1482815196_add_initial_tables.rb new file mode 100755 index 0000000..cda9bb7 --- /dev/null +++ b/application/migrate/1482815196_add_initial_tables.rb @@ -0,0 +1,14 @@ +Sequel.migration do + change do + create_table(:users) do + primary_key :id + String :first_name + String :last_name + String :password + String :email + DateTime :born_on + DateTime :created_at + DateTime :updated_at + end + end +end diff --git a/application/migrate/1488320988_add_user_tokens_table.rb b/application/migrate/1488320988_add_user_tokens_table.rb new file mode 100755 index 0000000..b4b64bf --- /dev/null +++ b/application/migrate/1488320988_add_user_tokens_table.rb @@ -0,0 +1,15 @@ +Sequel.migration do + change do + create_table(:user_tokens) do + primary_key :id + + String :access_token + DateTime :expires_at + Integer :user_id + Boolean :active + + index :user_id, unique: false + index :access_token, unique: true + end + end +end diff --git a/application/models/user.rb b/application/models/user.rb new file mode 100755 index 0000000..0621807 --- /dev/null +++ b/application/models/user.rb @@ -0,0 +1,35 @@ +require 'lib/abilities' + +class Api + module Models + class User < Sequel::Model(:users) + include AbilityList::Helpers + + one_to_many :user_tokens + + def after_create + update_token! + end + + def abilities + @abilities ||= Abilities.new(self) + end + + def full_name + "#{self.first_name} #{self.last_name}" + end + + def token + user_tokens.last.access_token + end + + def update_token! + user_tokens << UserToken.create(user_id: self.id) + end + + def password_confirmation=(pass) + nil + end + end + end +end diff --git a/application/models/user_token.rb b/application/models/user_token.rb new file mode 100755 index 0000000..c5a6e2d --- /dev/null +++ b/application/models/user_token.rb @@ -0,0 +1,25 @@ +class Api + module Models + class UserToken < Sequel::Model(:user_tokens) + many_to_one :user + + def before_create + generate_access_token + set_expiration + end + + def expired? + DateTime.now >= self.expires_at + end + + private + def generate_access_token + self.access_token = SecureRandom.hex + end + + def set_expiration + self.expires_at = DateTime.now + 1.day + end + end + end +end diff --git a/application/spec/api/users/get_spec.rb b/application/spec/api/users/get_spec.rb new file mode 100755 index 0000000..95899aa --- /dev/null +++ b/application/spec/api/users/get_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe 'GET /api/users' do + before :all do + @u1 = create :user + @u2 = create :user + end + + it 'should pull all users' do + get "api/v1.0/users" + body = response_body + emails = body[:data].map{ |x| x[:email] } + expect(emails).to include @u1.email + expect(emails).to include @u2.email + end +end diff --git a/application/spec/api/users/patch_spec.rb b/application/spec/api/users/patch_spec.rb new file mode 100644 index 0000000..516dbcb --- /dev/null +++ b/application/spec/api/users/patch_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe 'PATCH /api/users' do + before :all do + @user = create :user + password = Faker::Internet.password + @user_attributes = { + new_password: password, + new_password_confirmation: password + } + end + + it 'should update user password' do + header('token', @user.token) + patch("api/v1.0/users/#{@user.id}/reset_password", @user_attributes) + expect(last_response.status).to eq(200) + end + + it 'should NOT update user password, invalid token' do + header('token', @user.token) + @user2 = create :user + patch("api/v1.0/users/#{@user2.id}/reset_password", @user_attributes) + expect(last_response.status).to eq(401) + end + + it 'missing params, should NOT update user password' do + header('token', @user.token) + patch("api/v1.0/users/#{@user.id}/reset_password", {}) + body = response_body + expect(last_response.status).to eq(400) + expect(body.keys).to eq([:new_password]) + end + + it 'mismatch passwords, should NOT update user password' do + @user_attributes[:new_password] = Faker::Internet.password + header('token', @user.token) + patch("api/v1.0/users/#{@user.id}/reset_password", @user_attributes) + body = response_body + expect(last_response.status).to eq(400) + expect(body.keys).to eq([:new_password_confirmation]) + end +end diff --git a/application/spec/api/users/post_spec.rb b/application/spec/api/users/post_spec.rb new file mode 100644 index 0000000..b87cf53 --- /dev/null +++ b/application/spec/api/users/post_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe 'POST /api/users' do + before :all do + password = Faker::Internet.password + @user_attributes = { + first_name: Faker::Name.first_name, + last_name: Faker::Name.last_name, + email: Faker::Internet.email, + password: password, + password_confirmation: password, + born_on: Faker::Date.birthday.to_time + } + end + + it 'should create a new user' do + post('api/v1.0/users', @user_attributes) + expect(last_response.status).to eq(201) + end + + it 'should create a new user without born_on' do + @user_attributes.delete(:born_on) + post('api/v1.0/users', @user_attributes) + expect(last_response.status).to eq(201) + end + + it 'missing params, should NOT create a new user' do + post('api/v1.0/users', {}) + body = response_body + expect(last_response.status).to eq(400) + expect(body.keys).to eq([:first_name, :last_name, :email, :password]) + end + + it 'wrong born_on format, should NOT create a new user' do + @user_attributes[:born_on] = Faker::Date.birthday.to_s + post('api/v1.0/users', @user_attributes) + body = response_body + expect(last_response.status).to eq(400) + expect(body).to eq(born_on: ['must be in format YYYY-MM-DD HH:MM:SS']) + end +end diff --git a/application/spec/api/users/put_spec.rb b/application/spec/api/users/put_spec.rb new file mode 100644 index 0000000..d21eaac --- /dev/null +++ b/application/spec/api/users/put_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe 'PUT /api/users' do + before :all do + @user = create :user + @user_attributes = { + first_name: Faker::Name.first_name, + last_name: Faker::Name.last_name, + email: Faker::Internet.email, + born_on: Faker::Date.birthday.to_time + } + end + + it 'should update user attributes' do + header('token', @user.token) + put("api/v1.0/users/#{@user.id}", @user_attributes) + expect(last_response.status).to eq(200) + end + + it 'should NOT update user attributes, invalid token' do + header('token', @user.token) + @user2 = create :user + put("api/v1.0/users/#{@user2.id}", @user_attributes) + expect(last_response.status).to eq(401) + end + + it 'missing params, should NOT update user attributes' do + header('token', @user.token) + put("api/v1.0/users/#{@user.id}", {}) + body = response_body + expect(last_response.status).to eq(400) + expect(body.keys).to eq([:first_name, :last_name, :email]) + end + + it 'wrong born_on format, should NOT update user attributes' do + @user_attributes[:born_on] = Faker::Date.birthday.to_s + header('token', @user.token) + put("api/v1.0/users/#{@user.id}", @user_attributes) + body = response_body + expect(last_response.status).to eq(400) + expect(body).to eq(born_on: ['must be in format YYYY-MM-DD HH:MM:SS']) + end +end diff --git a/application/spec/factories/user.rb b/application/spec/factories/user.rb new file mode 100755 index 0000000..199c292 --- /dev/null +++ b/application/spec/factories/user.rb @@ -0,0 +1,9 @@ +FactoryGirl.define do + factory :user, class: Api::Models::User do + first_name { Faker::Name.first_name } + last_name { Faker::Name.last_name } + email { Faker::Internet.email } + password Digest::MD5.hexdigest 'test' + born_on Date.new(2000, 1, 1) + end +end diff --git a/application/spec/spec_helper.rb b/application/spec/spec_helper.rb new file mode 100755 index 0000000..bdefb7c --- /dev/null +++ b/application/spec/spec_helper.rb @@ -0,0 +1,89 @@ +require 'rspec/core' +require 'rack/test' +ENV['RACK_ENV'] = 'test' +require './application/api' +require 'faker' +require 'factory_girl' + +Mail.defaults do + delivery_method :test +end + +# Load up all application files that we'll be testing in the suites +Dir['./application/models/**/*.rb'].sort.each { |rb| require rb } + +FactoryGirl.definition_file_paths = %w{./application/spec/factories} +FactoryGirl.find_definitions + +# Factory Girl is expecting ActiveRecord +class Sequel::Model + alias_method :save!, :save +end + +class RSpecConstants +end + +module RSpecHelpers + include Rack::Test::Methods + + def login_as user + Api.class_variable_set(:@@current_user, user) + end + + def app + Api + end + + def response_body + JSON.parse(last_response.body, symbolize_names: true) + end + + def get_scope opts = {} + scope = Api.new + scope.instance_variable_set(:@current_user, opts[:as_user]) + scope + end +end + +class Api + helpers do + def current_user + begin + @current_user = Api.class_variable_get(:@@current_user) + rescue + nil + end + end + end +end + +SEQUEL_DB = Api::SEQUEL_DB +# Clear old test data +SEQUEL_DB.tables.each do |t| + # we don't want to clean the schema_migrations table + SEQUEL_DB.from(t).truncate unless t == :schema_migrations || t.to_s.match(/^oauth_/) +end + +Faker::Config.locale = 'en-US' + +RSpec.configure do |config| + config.extend RSpecHelpers + config.include RSpecHelpers + config.include FactoryGirl::Syntax::Methods + config.filter_run_excluding :slow + config.color = true + config.tty = true + config.formatter = :documentation + + config.around(:all) do |example| + Sequel.transaction [SEQUEL_DB], rollback: :always do + example.run + end + end + + config.around(:each) do |example| + Sequel.transaction([SEQUEL_DB], rollback: :always, savepoint: true, auto_savepoint: true) do + example.run + end + end +end diff --git a/application/tasks/db.rake b/application/tasks/db.rake new file mode 100755 index 0000000..2e6eec2 --- /dev/null +++ b/application/tasks/db.rake @@ -0,0 +1,73 @@ +namespace :db do + desc 'Prints current schema version' + task :version do + version = if SEQUEL_DB.tables.include?(:schema_migrations) + SEQUEL_DB[:schema_migrations].order(:filename.desc).first[:filename] + end || 0 + + puts "Schema Version: #{version}" + end + + desc 'Perform migration up to latest migration available' + task :migrate do + Sequel::Migrator.run(SEQUEL_DB, 'application/migrate') + Rake::Task['db:version'].execute + end + + desc 'Perform migration up to latest migration available' + task :seed do + require './db/seeds' + end + + desc 'Perform rollback to specified target or full rollback as default' + task :rollback, :target do |t, args| + unless args[:target] + migrations = SEQUEL_DB[:schema_migrations].order(:filename).all + target = migrations[migrations.length - 2][:filename] + end + + args.with_defaults(target: target) + + Sequel::Migrator.run(SEQUEL_DB, 'application/migrate', target: args[:target].to_i) + Rake::Task['db:version'].execute + end + + desc "Perform migration reset (full rollback and migration)" + task :reset do + Sequel::Migrator.run(SEQUEL_DB, "application/migrate", :target => 0) + Sequel::Migrator.run(SEQUEL_DB, "application/migrate") + Rake::Task['db:version'].execute + end + + desc "Remove all database tables and re-run migrations" + task :refresh do + if ENV['RACK_ENV'] != 'test' + puts 'Refresh only permitted in test environment.' + exit false + end + + SEQUEL_DB.tables.each do |t| + SEQUEL_DB.drop_table t.to_sym + end + + Rake::Task['db:migrate'].execute + end + + desc 'Generate a timestamped, empty Sequel migration.' + task :migration, :name do |_, args| + if args[:name].nil? + puts 'You must specify a migration name (e.g. rake generate:migration[create_events])!' + exit false + end + + content = "Sequel.migration do\n change do\n\n end\nend\n" + timestamp = Time.now.to_i + filename = File.join('application/migrate', "#{timestamp}_#{args[:name]}.rb") + + File.open(filename, 'w') do |f| + f.puts content + end + + puts "Created the migration #{filename}" + end +end diff --git a/application/validations/create_user_validation.rb b/application/validations/create_user_validation.rb new file mode 100644 index 0000000..953a578 --- /dev/null +++ b/application/validations/create_user_validation.rb @@ -0,0 +1,12 @@ +class CreateUserValidation + include Hanami::Validations::Form + predicates FormPredicates + + validations do + required(:first_name).filled(:str?) + required(:last_name).filled(:str?) + required(:email).filled(:str?, :email?) + required(:password).filled.confirmation + optional(:born_on).filled(:datetime_str?) + end +end diff --git a/application/validations/edit_user_validation.rb b/application/validations/edit_user_validation.rb new file mode 100644 index 0000000..689c906 --- /dev/null +++ b/application/validations/edit_user_validation.rb @@ -0,0 +1,11 @@ +class EditUserValidation + include Hanami::Validations::Form + predicates FormPredicates + + validations do + required(:first_name).maybe(:str?) + required(:last_name).maybe(:str?) + required(:email).maybe(:str?, :email?) + optional(:born_on).maybe(:datetime_str?) + end +end diff --git a/application/validations/reset_password_user_validation.rb b/application/validations/reset_password_user_validation.rb new file mode 100644 index 0000000..b5e1f43 --- /dev/null +++ b/application/validations/reset_password_user_validation.rb @@ -0,0 +1,7 @@ +class ResetPasswordUserValidation + include Hanami::Validations::Form + + validations do + required(:new_password).filled.confirmation + end +end diff --git a/config.ru b/config.ru new file mode 100755 index 0000000..73e2b76 --- /dev/null +++ b/config.ru @@ -0,0 +1,5 @@ +require File.expand_path '../application/api.rb', __FILE__ +# Grape Batch needs to be added as a Rack Middleware in order to intercept +# batch requests +use Grape::Batch::Base +run Api