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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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