praxis 2.0.pre.10 → 2.0.pre.15

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/.travis.yml +1 -3
  4. data/CHANGELOG.md +26 -0
  5. data/bin/praxis +65 -2
  6. data/lib/praxis/api_definition.rb +8 -4
  7. data/lib/praxis/bootloader_stages/environment.rb +1 -0
  8. data/lib/praxis/collection.rb +11 -0
  9. data/lib/praxis/docs/open_api/response_object.rb +21 -6
  10. data/lib/praxis/docs/open_api_generator.rb +1 -1
  11. data/lib/praxis/extensions/attribute_filtering.rb +14 -1
  12. data/lib/praxis/extensions/attribute_filtering/active_record_filter_query_builder.rb +206 -66
  13. data/lib/praxis/extensions/attribute_filtering/filter_tree_node.rb +3 -2
  14. data/lib/praxis/extensions/attribute_filtering/filtering_params.rb +45 -41
  15. data/lib/praxis/extensions/attribute_filtering/filters_parser.rb +193 -0
  16. data/lib/praxis/extensions/attribute_filtering/sequel_filter_query_builder.rb +20 -8
  17. data/lib/praxis/extensions/pagination.rb +5 -32
  18. data/lib/praxis/mapper/active_model_compat.rb +4 -0
  19. data/lib/praxis/mapper/resource.rb +18 -2
  20. data/lib/praxis/mapper/selector_generator.rb +1 -0
  21. data/lib/praxis/mapper/sequel_compat.rb +7 -0
  22. data/lib/praxis/media_type_identifier.rb +11 -1
  23. data/lib/praxis/plugins/mapper_plugin.rb +22 -13
  24. data/lib/praxis/plugins/pagination_plugin.rb +34 -4
  25. data/lib/praxis/response_definition.rb +46 -66
  26. data/lib/praxis/responses/http.rb +3 -1
  27. data/lib/praxis/tasks/api_docs.rb +4 -1
  28. data/lib/praxis/tasks/routes.rb +6 -6
  29. data/lib/praxis/version.rb +1 -1
  30. data/spec/praxis/action_definition_spec.rb +3 -1
  31. data/spec/praxis/extensions/attribute_filtering/active_record_filter_query_builder_spec.rb +267 -167
  32. data/spec/praxis/extensions/attribute_filtering/filter_tree_node_spec.rb +25 -6
  33. data/spec/praxis/extensions/attribute_filtering/filtering_params_spec.rb +100 -17
  34. data/spec/praxis/extensions/attribute_filtering/filters_parser_spec.rb +148 -0
  35. data/spec/praxis/extensions/field_selection/active_record_query_selector_spec.rb +1 -1
  36. data/spec/praxis/extensions/field_selection/sequel_query_selector_spec.rb +1 -1
  37. data/spec/praxis/extensions/support/spec_resources_active_model.rb +1 -1
  38. data/spec/praxis/mapper/selector_generator_spec.rb +1 -1
  39. data/spec/praxis/media_type_identifier_spec.rb +15 -1
  40. data/spec/praxis/response_definition_spec.rb +37 -129
  41. data/tasks/thor/example.rb +12 -6
  42. data/tasks/thor/model.rb +40 -0
  43. data/tasks/thor/scaffold.rb +117 -0
  44. data/tasks/thor/templates/generator/empty_app/config/environment.rb +1 -0
  45. data/tasks/thor/templates/generator/example_app/Rakefile +9 -2
  46. data/tasks/thor/templates/generator/example_app/app/v1/concerns/controller_base.rb +24 -0
  47. data/tasks/thor/templates/generator/example_app/app/v1/concerns/href.rb +33 -0
  48. data/tasks/thor/templates/generator/example_app/app/v1/controllers/users.rb +2 -2
  49. data/tasks/thor/templates/generator/example_app/app/v1/resources/base.rb +15 -0
  50. data/tasks/thor/templates/generator/example_app/app/v1/resources/user.rb +7 -28
  51. data/tasks/thor/templates/generator/example_app/config.ru +1 -2
  52. data/tasks/thor/templates/generator/example_app/config/environment.rb +3 -2
  53. data/tasks/thor/templates/generator/example_app/db/migrate/20201010101010_create_users_table.rb +3 -2
  54. data/tasks/thor/templates/generator/example_app/db/seeds.rb +6 -0
  55. data/tasks/thor/templates/generator/example_app/design/v1/endpoints/users.rb +4 -4
  56. data/tasks/thor/templates/generator/example_app/design/v1/media_types/user.rb +1 -6
  57. data/tasks/thor/templates/generator/example_app/spec/helpers/database_helper.rb +4 -2
  58. data/tasks/thor/templates/generator/example_app/spec/spec_helper.rb +2 -2
  59. data/tasks/thor/templates/generator/example_app/spec/v1/controllers/users_spec.rb +2 -2
  60. data/tasks/thor/templates/generator/scaffold/design/endpoints/collection.rb +98 -0
  61. data/tasks/thor/templates/generator/scaffold/design/media_types/item.rb +18 -0
  62. data/tasks/thor/templates/generator/scaffold/implementation/controllers/collection.rb +77 -0
  63. data/tasks/thor/templates/generator/scaffold/implementation/resources/base.rb +11 -0
  64. data/tasks/thor/templates/generator/scaffold/implementation/resources/item.rb +45 -0
  65. data/tasks/thor/templates/generator/scaffold/models/active_record.rb +6 -0
  66. data/tasks/thor/templates/generator/scaffold/models/sequel.rb +6 -0
  67. metadata +21 -6
@@ -33,16 +33,22 @@ module PraxisGen
33
33
  puts
34
34
  puts " cd #{app_name}"
35
35
  puts " bundle"
36
- puts " bundle exec rake db:create db:migrate db:seed # To create/migrate/seed the dev DB"
37
- puts " bundle exec rackup # To start the web server"
36
+ puts " bundle exec rake db:recreate # To create/migrate/seed the dev DB"
37
+ puts " bundle exec rackup # To start the web server"
38
38
  puts
39
39
  puts "From another terminal/app, use curl (or your favorite HTTP client) to retrieve data from the API"
40
40
  puts " For example: "
41
- puts " Get all users without filters or limit, and display only uid, and last_name fields"
42
- puts " curl -H 'X-Api-Version: 1' http://localhost:9292/users?fields=uid,last_name"
41
+ puts " Get all users without filters or limit, and display only id, and first_name fields"
42
+ puts " curl -G -H 'X-Api-Version: 1' http://localhost:9292/users \\"
43
+ puts " --data-urlencode \"fields=id,first_name\""
43
44
  puts
44
- puts " Get the last 5 users, ordered by last_name (descending), and display only uid, and last_name fields"
45
- puts " curl -H 'X-Api-Version: 1' 'http://localhost:9292/users?fields=uid,last_name&order=-last_name&pagination=by%3Dlast_name,items%3D5' "
45
+ puts " Get the last 5 users, with last_names starting with \"L\" ordered by first_name (descending)"
46
+ puts " and display only id, first_name, last_name, and email fields"
47
+ puts " curl -G -H 'X-Api-Version: 1' http://localhost:9292/users \\"
48
+ puts " --data-urlencode \"filters=last_name=L*\" \\"
49
+ puts " --data-urlencode \"pagination=by=first_name,items=5\" \\"
50
+ puts " --data-urlencode \"order=-first_name\" \\"
51
+ puts " --data-urlencode \"fields=id,first_name,last_name,email\""
46
52
  puts " (Note: To list all routes use: bundle exec rake praxis:routes)"
47
53
  puts
48
54
  nil
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PraxisGen
4
+ class Model < Thor
5
+ require 'active_support/inflector'
6
+ include Thor::Actions
7
+
8
+ def self.source_root
9
+ File.dirname(__FILE__) + "/templates/generator/scaffold"
10
+ end
11
+
12
+ desc "gmodel", "Generates a skeleton model file under app/models for ActiveRecord or Sequel."
13
+ argument :model_name, required: true
14
+ option :orm, required: false, default: 'activerecord', enum: ['activerecord','sequel']
15
+ def g
16
+ #self.class.check_name(model_name)
17
+ template_file = \
18
+ if options[:orm] == 'activerecord'
19
+ 'models/active_record.rb'
20
+ else
21
+ 'models/sequel.rb'
22
+ end
23
+ puts "Generating Model for #{model_name}"
24
+ template template_file, "app/models/#{model_name}.rb"
25
+ nil
26
+ end
27
+ # Helper functions (which are available in the ERB contexts)
28
+ no_commands do
29
+ def model_class
30
+ model_name.camelize
31
+ end
32
+ end
33
+
34
+ # TODO: do we want the argument to be camelcase? or snake case?
35
+ def self.check_name(name)
36
+ sanitized = name.downcase.gsub(/[^a-z0-9_]/, '')
37
+ raise "Please use only downcase letters, numbers and underscores for the model" unless sanitized == name
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PraxisGen
4
+ class Scaffold < Thor
5
+ require 'active_support/inflector'
6
+ include Thor::Actions
7
+
8
+ attr_reader :actions_hash
9
+
10
+ def self.source_root
11
+ File.dirname(__FILE__) + "/templates/generator/scaffold"
12
+ end
13
+
14
+ desc "g","Generates an API design and implementation scaffold for managing a collection of <collection_name>"
15
+ argument :collection_name, required: true
16
+ option :version, required: false, default: '1',
17
+ desc: 'Version string for the API endpoint. This also dictates the directory structure (i.e., v1/endpoints/...))'
18
+ option :design, type: :boolean, default: true,
19
+ desc: 'Include the Endpoint and MediaType files for the collection'
20
+ option :implementation, type: :boolean, default: true,
21
+ desc: 'Include the Controller and (possibly the) Resource files for the collection (see --no-resource)'
22
+ option :resource, type: :boolean, default: true,
23
+ desc: 'Disable (or enable) the creation of the Resource files when generating implementation'
24
+ option :model, type: :string, enum: ['activerecord','sequel'],
25
+ desc: 'It also generates a model for the given ORM. An empty --model flag will default to activerecord'
26
+ option :actions, type: :string, default: 'crud', enum: ['cr','cru','crud','u','ud','d'],
27
+ desc: 'Specifies the actions to generate for the API. cr=create, u=update, d=delete. Index and show actions are always generated'
28
+ def g
29
+ self.class.check_name(collection_name)
30
+ @actions_hash = self.class.compose_actions_hash(options[:actions])
31
+ env_rb = Pathname.new(destination_root)+Pathname.new("config/environment.rb")
32
+ @pagination_plugin_found = File.open(env_rb).grep(/Praxis::Plugins::PaginationPlugin.*/).reject{|l| l.strip[0] == '#'}.present?
33
+ if options[:design]
34
+ say_status 'Design', "Generating scaffold for #{plural_class}", :blue
35
+ template 'design/media_types/item.rb', "design/#{version_dir}/media_types/#{collection_name.singularize}.rb"
36
+ template 'design/endpoints/collection.rb', "design/#{version_dir}/endpoints/#{collection_name}.rb"
37
+ end
38
+ if options[:implementation]
39
+ say_status 'Implement', "Generating scaffold for #{plural_class}", :blue
40
+ if options[:resource]
41
+ base_resource = Pathname.new(destination_root)+Pathname.new("app/#{version_dir}/resources/base.rb")
42
+ unless base_resource.exist?
43
+ # Copy an appropriate base resource for the version (resources within same version must share same base)
44
+ say_status "NOTE:",
45
+ "Creating a base resource file for resources to inherit from (at 'app/#{version_dir}/resources/base.rb')",
46
+ :yellow
47
+ say_status "",
48
+ "If you had already other resources in the app, change them to derive from this Base"
49
+ template 'implementation/resources/base.rb', "app/#{version_dir}/resources/base.rb"
50
+ end
51
+ template 'implementation/resources/item.rb', "app/#{version_dir}/resources/#{collection_name.singularize}.rb"
52
+ end
53
+ template 'implementation/controllers/collection.rb', "app/#{version_dir}/controllers/#{collection_name}.rb"
54
+ end
55
+ nil
56
+ end
57
+
58
+ # Helper functions (which are available in the ERB contexts)
59
+ no_commands do
60
+ def plural_class
61
+ collection_name.camelize
62
+ end
63
+
64
+ def singular_class
65
+ collection_name.singularize.camelize
66
+ end
67
+
68
+ def version
69
+ options[:version]
70
+ end
71
+
72
+ def version_module
73
+ "V#{version}"
74
+ end
75
+
76
+ def version_dir
77
+ version_module.camelize(:lower)
78
+ end
79
+
80
+ def action_enabled?(action)
81
+ @actions_hash[action.to_sym]
82
+ end
83
+
84
+ def pagination_enabled?
85
+ @pagination_plugin_found
86
+ end
87
+ end
88
+
89
+ def self.compose_actions_hash(actions_opt)
90
+ required = { index: true, show: true }
91
+ case actions_opt
92
+ when nil
93
+ required
94
+ when 'cr'
95
+ required.merge(create: true)
96
+ when 'cru'
97
+ required.merge(create: true, update: true)
98
+ when 'crud'
99
+ required.merge(create: true, update: true, delete: true)
100
+ when 'u'
101
+ required.merge(update: true)
102
+ when 'ud'
103
+ required.merge(update: true, delete: true)
104
+ when 'd'
105
+ required.merge(delete: true)
106
+ else
107
+ raise "actions option does not support the string #{actions_opt}"
108
+ end
109
+ end
110
+
111
+ def self.check_name(name)
112
+ sanitized = name.downcase.gsub(/[^a-z0-9_]/, '')
113
+ # TODO: bail or support CamelCase collections (for now only snake case)
114
+ raise "Please use only downcase letters, numbers and underscores for the collection" unless sanitized == name
115
+ end
116
+ end
117
+ end
@@ -18,6 +18,7 @@ Praxis::Application.configure do |application|
18
18
  # map :models, 'models/**/*'
19
19
  # map :responses, '**/responses/**/*'
20
20
  # map :exceptions, '**/exceptions/**/*'
21
+ # map :concerns, '**/concerns/**/*'
21
22
  # map :resources, '**/resources/**/*'
22
23
  # map :controllers, '**/controllers/**/*'
23
24
  # end
@@ -32,10 +32,17 @@ namespace :db do
32
32
  puts "Database migrated."
33
33
  end
34
34
 
35
+ desc 'Fully receate, migrate and seed the DB'
36
+ task :recreate do
37
+ Rake::Task['db:drop'].invoke rescue nil
38
+ Rake::Task['db:create'].invoke
39
+ Rake::Task['db:migrate'].invoke
40
+ Rake::Task['db:seed'].invoke
41
+ end
42
+
35
43
  desc 'seed with example data'
36
44
  task seed: 'praxis:environment' do
37
- require_relative 'spec/helpers/database_helper'
38
- DatabaseHelper.seed!
45
+ require_relative 'db/seeds.rb'
39
46
  end
40
47
 
41
48
  desc 'drops current database'
@@ -0,0 +1,24 @@
1
+ module V1
2
+ module Concerns
3
+ module ControllerBase
4
+ extend ActiveSupport::Concern
5
+ # Controller concen that wraps an API with a transaction, and automatically rolls it back
6
+ # for non-2xx (or 3xx) responses
7
+ included do
8
+ around :action do |controller, callee|
9
+ begin
10
+ # TODO: Support Sequel as well
11
+ ActiveRecord::Base.transaction do
12
+ callee.call
13
+ res = controller.response
14
+ # Force a rollback for non 2xx or 3xx responses
15
+ raise ActiveRecord::Rollback unless res.status >= 200 && res.status < 400
16
+ end
17
+ rescue ActiveRecord::Rollback
18
+ # No need to do anything, let the responses flow normally
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module V1
4
+ module Resources
5
+ module Concerns
6
+ module Href
7
+ extend ActiveSupport::Concern
8
+
9
+ # Base module where the href concern will grab constants from
10
+ included do
11
+ def self.base_module
12
+ ::V1
13
+ end
14
+ end
15
+
16
+ module ClassMethods
17
+ def endpoint_path_template
18
+ # memoize a templated path for an endpoint, like
19
+ # /im/contacts/%{id}
20
+ return @endpoint_path_template if @endpoint_path_template
21
+
22
+ path = self.base_module.const_get(:Endpoints).const_get(model.name.split(':').last.pluralize).canonical_path.route.path
23
+ @endpoint_path_template = path.names.inject(path.to_s) { |p, name| p.sub(':' + name, "%{#{name}}") }
24
+ end
25
+ end
26
+
27
+ def href
28
+ format(self.class.endpoint_path_template, id: id)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -4,8 +4,8 @@ module V1
4
4
  module Controllers
5
5
  class Users
6
6
  include Praxis::Controller
7
- include Praxis::Extensions::Rendering
8
-
7
+ include Concerns::ControllerBase
8
+
9
9
  implements V1::Endpoints::Users
10
10
 
11
11
  def index
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../concerns/href'
4
+
5
+ module V1
6
+ module Resources
7
+ class Base < Praxis::Mapper::Resource
8
+ include Resources::Concerns::Href
9
+
10
+ # Base for all V1 resources.
11
+ # Resources withing a single version should have resource mappings separate from other versions
12
+ # and the Mapper::Resource will appropriately maintain different model_maps for each Base classes
13
+ end
14
+ end
15
+ end
@@ -2,42 +2,21 @@
2
2
 
3
3
  module V1
4
4
  module Resources
5
- class User < Praxis::Mapper::Resource
5
+ class User < Base
6
6
  model ::User
7
7
 
8
- # Mappings for the allowed filterd
8
+ # Mappings for the allowed filters
9
9
  filters_mapping(
10
- 'email': 'email',
11
- 'first_name': 'first_name',
12
- # Complex (convoluted?) mapping of state, just to show how we can modify and adapt the values/fields/operators
13
- 'state': lambda do |spec|
14
- case spec[:value].to_s
15
- when 'pending' # Pending users do not have a uuid
16
- { name: :uuid, value: nil, op: spec[:op] }
17
- when 'active' # Active users do not have a uuid (so "flip" the original equality condition)
18
- opposite_op = spec[:op] == '=' ? '!=' : '='
19
- { name: :uuid, value: nil, op: opposite_op }
20
- else
21
- raise "Cannot filter users by state #{spec[:value]}"
22
- end
23
- end,
10
+ 'uuid': 'uuid',
11
+ 'first_name': 'first_name',
12
+ 'last_name': 'last_name',
13
+ 'email': 'email'
24
14
  )
25
15
 
26
- # Example of a property that depends on a differently named DB field
27
- property :uid, dependencies: %i[id]
28
16
  # To compute the full_name (method below) we need to load first and last names from the DB
29
17
  property :full_name, dependencies: %i[first_name last_name]
30
18
 
31
- def uid
32
- id # underlying id field of the model
33
- end
34
-
35
- # Computed attribute: if uuid nil, user in in a pending stat, else active
36
- def state
37
- self.uuid.nil? ? 'pending' : 'active'
38
- end
39
-
40
- # Computed attribute the combines first and last
19
+ # Computed attribute that combines first and last
41
20
  def full_name
42
21
  [first_name, last_name].join(' ')
43
22
  end
@@ -8,10 +8,9 @@ Bundler.require(:default, ENV['RACK_ENV'])
8
8
  # API field selection (a la GraphQL) - for querying and rendering
9
9
  # API filtering extensions (to add "where clauses") in listings
10
10
  # Views and partial rendering (for ActiveRecord models)
11
-
12
11
  require 'praxis/plugins/mapper_plugin'
13
12
  require 'praxis/mapper/active_model_compat'
14
- require 'praxis/extensions/field_selection'
13
+ # Want to take advantage of the pagination and sorting extensions as well
15
14
  require 'praxis/plugins/pagination_plugin'
16
15
 
17
16
  # Start the sqlite DB
@@ -3,8 +3,8 @@ Praxis::Application.configure do |application|
3
3
 
4
4
  # Configure the Mapper plugin (if we want to use all the filtering/field_selection extensions)
5
5
  application.bootloader.use Praxis::Plugins::MapperPlugin
6
- # Cconfigure the Pagination plugin (if we want to use all the pagination/ordering extensions)
7
- application.bootloader.use Praxis::Plugins::PaginationPlugin, {
6
+ # Configure the Pagination plugin (if we want to use all the pagination/ordering extensions)
7
+ application.bootloader.use Praxis::Plugins::PaginationPlugin, **{
8
8
  # max_items: 500, # Unlimited by default,
9
9
  # default_page_size: 100,
10
10
  # paging_default_mode: {by: :id},
@@ -33,6 +33,7 @@ Praxis::Application.configure do |application|
33
33
  # map :models, 'models/**/*'
34
34
  # map :responses, '**/responses/**/*'
35
35
  # map :exceptions, '**/exceptions/**/*'
36
+ # map :concerns, '**/concerns/**/*'
36
37
  # map :resources, '**/resources/**/*'
37
38
  # map :controllers, '**/controllers/**/*'
38
39
  # end
@@ -3,9 +3,10 @@
3
3
  class CreateUsersTable < ActiveRecord::Migration[5.2]
4
4
  def change
5
5
  create_table :users do |table|
6
- table.column :uuid, :integer
7
- table.column :first_name, :string
6
+ table.column :uuid, :string, null: false
7
+ table.column :first_name, :string, null: false
8
8
  table.column :last_name, :string
9
+ table.column :email, :string
9
10
  end
10
11
  end
11
12
  end
@@ -0,0 +1,6 @@
1
+
2
+ require_relative '../spec/helpers/database_helper'
3
+ # Invoke the encapsulated seed class...
4
+ DatabaseHelper.seed!
5
+
6
+ # Add more seeds here..
@@ -4,7 +4,6 @@ module V1
4
4
  module Endpoints
5
5
  class Users
6
6
  include Praxis::EndpointDefinition
7
- # include AuthenticatedEndpoint
8
7
 
9
8
  media_type MediaTypes::User
10
9
  version '1'
@@ -18,15 +17,16 @@ module V1
18
17
  attribute :fields, Praxis::Types::FieldSelector.for(MediaTypes::User),
19
18
  description: 'Fields with which to render the result.'
20
19
  attribute :filters, Praxis::Types::FilteringParams.for(MediaTypes::User) do
21
- filter 'state', using: ['=', '!=']
20
+ filter 'uuid', using: ['=', '!=']
22
21
  filter 'first_name', using: ['=', '!='], fuzzy: true
23
22
  filter 'last_name', using: ['=', '!='], fuzzy: true
23
+ filter 'email', using: ['=', '!=']
24
24
  end
25
25
  attribute :pagination, Praxis::Types::PaginationParams.for(MediaTypes::User) do
26
- by_fields :uid, :first_name, :last_name
26
+ by_fields :uuid, :first_name, :last_name
27
27
  end
28
28
  attribute :order, Praxis::Extensions::Pagination::OrderingParams.for(MediaTypes::User) do
29
- by_fields :uid, :last_name, :first_name
29
+ by_fields :uuid, :last_name, :first_name
30
30
  end
31
31
  end
32
32
  response :ok, media_type: Praxis::Collection.of(MediaTypes::User)
@@ -9,16 +9,11 @@ module V1
9
9
  description 'A user in the system'
10
10
 
11
11
  attributes do
12
- attribute :uid, String
12
+ attribute :id, Integer
13
13
  attribute :uuid, String
14
14
  attribute :email, String
15
15
  attribute :first_name, String
16
16
  attribute :last_name, String
17
- attribute :state, String, values: %i[pending active]
18
- end
19
-
20
- default_fieldset do
21
- attribute :uid
22
17
  end
23
18
  end
24
19
  end