propel_facets 0.1.1

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.
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'unpack_generator'
3
+ ##
4
+ # Generator to install propel_facets functionality in a Rails application
5
+ #
6
+ # This creates a COMPLETELY STANDALONE facets system with no gem dependencies.
7
+ # Usage:
8
+ # rails generate propel_facets:install # Full standalone system (runtime + generators)
9
+ #
10
+ # This generator:
11
+ # 1. Installs all runtime code (concerns, utilities, errors, config, etc.)
12
+ # 2. Automatically extracts generator logic to lib/generators/propel_facets/
13
+ # 3. Creates a system that requires NO gem dependencies
14
+ #
15
+ # After installation, you can remove 'propel_facets' from your Gemfile completely.
16
+ # All code is in your application and fully customizable.
17
+ #
18
+ module PropelFacets
19
+ class InstallGenerator < Rails::Generators::Base
20
+ source_root File.expand_path("templates", __dir__)
21
+
22
+ desc "Install PropelFacets functionality for Rails applications"
23
+
24
+ def copy_configuration_initializer
25
+ copy_file "config/propel_facets.rb", "config/initializers/propel_facets.rb"
26
+ end
27
+
28
+ def copy_model_concerns
29
+ copy_file "models/concerns/model_facet.rb", "app/models/concerns/model_facet.rb"
30
+ end
31
+
32
+ def copy_controller_concerns
33
+ copy_file "controllers/concerns/facet_renderer.rb", "app/controllers/concerns/facet_renderer.rb"
34
+ copy_file "controllers/concerns/strong_params_helper.rb", "app/controllers/concerns/strong_params_helper.rb"
35
+ end
36
+
37
+ def copy_utility_classes
38
+ copy_file "lib/api_params.rb", "lib/api_params.rb"
39
+ end
40
+
41
+ def copy_error_classes
42
+ copy_file "errors/application_error.rb", "app/errors/application_error.rb"
43
+ copy_file "errors/missing_facet.rb", "app/errors/missing_facet.rb"
44
+ end
45
+
46
+ def copy_documentation
47
+ copy_file "doc/json_facet.md", "doc/json_facet.md"
48
+ end
49
+
50
+ def create_application_record_if_needed
51
+ application_record_path = "app/models/application_record.rb"
52
+
53
+ unless File.exist?(File.join(destination_root, application_record_path))
54
+ copy_file "models/application_record.rb", application_record_path
55
+ else
56
+ say_status :exists, application_record_path, :blue
57
+ add_propel_facets_to_application_record
58
+ end
59
+ end
60
+
61
+ def extract_generator_for_customization
62
+ generator_path = "lib/generators/propel_facets"
63
+
64
+ if File.exist?(generator_path)
65
+ say ""
66
+ say "šŸ“¦ Generator logic already extracted at #{generator_path}/", :blue
67
+ say "šŸ’” Skipping extraction to preserve your customizations", :cyan
68
+ else
69
+ say ""
70
+ say "šŸ“¦ Extracting generator logic for full customization...", :blue
71
+
72
+ # Automatically run the unpack generator to extract generator logic
73
+ invoke PropelFacets::UnpackGenerator, [], { force: false }
74
+
75
+ say ""
76
+ say "āœ… Generator logic extracted to lib/generators/propel_facets/", :green
77
+ say "šŸ’” Your application is now completely standalone - no gem dependency needed for generators!", :cyan
78
+ say "šŸ—‘ļø You can now remove 'propel_facets' from your Gemfile", :yellow
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def add_propel_facets_to_application_record
85
+ inject_into_file "app/models/application_record.rb", after: "class ApplicationRecord < ActiveRecord::Base\n" do
86
+ <<-RUBY
87
+ include ModelFacet
88
+
89
+ # Default JSON facets for all models
90
+ json_facet :reference, fields: [:id, :type]
91
+ json_facet :included, base: :reference
92
+ json_facet :short, base: :included, include_as: :reference
93
+ json_facet :details, base: :short, include_as: :included
94
+
95
+ RUBY
96
+ end
97
+ end
98
+
99
+ def show_usage_instructions
100
+ say ""
101
+ say "PropelFacets has been installed!", :green
102
+
103
+ say "\nšŸ“¦ System Status: Completely standalone - no gem dependency!", :green
104
+ say "\nNext steps:", :yellow
105
+ say "• Optional: Remove 'propel_facets' from Gemfile (system is fully extracted)", :cyan
106
+
107
+ say ""
108
+ say "šŸ”§ Configuration:", :bold
109
+ say " Edit config/initializers/propel_facets.rb to customize:"
110
+ say " • JSON root structure (:data, :model, :class, :none)"
111
+ say " • API format (:rest, :jsonapi, :openapi, :graphql, etc.)"
112
+ say " • Default facets and inheritance patterns"
113
+ say " • Performance and caching options"
114
+ say ""
115
+ say "Usage in Models:", :bold
116
+ say " class User < ApplicationRecord"
117
+ say " json_facet :short, fields: [:name, :email]"
118
+ say " json_facet :details, base: :short, fields: [:created_at], methods: [:full_name]"
119
+ say " end"
120
+ say ""
121
+ say "Usage in Controllers:", :bold
122
+ say " class UsersController < ApplicationController"
123
+ say " include FacetRenderer"
124
+ say " include StrongParamsHelper"
125
+ say ""
126
+ say " connect_facet :short, actions: [:index]"
127
+ say " connect_facet :details, actions: [:show]"
128
+ say ""
129
+ say " permitted_params :name, :email, :role"
130
+ say ""
131
+ say " def index"
132
+ say " users = User.all"
133
+ say " render json: { data: users.map { |u| resource_json(u) } }"
134
+ say " end"
135
+ say " end"
136
+ say ""
137
+ say "Advanced Features:", :bold
138
+ say " permitted_unknown_params :metadata, :settings # Handle dynamic JSON"
139
+ say " PropelFacets.configure { |c| c.root = :none } # Flat JSON structure"
140
+
141
+ say ""
142
+ say "šŸŽØ Customization:", :bold
143
+ say "• Generator logic: lib/generators/propel_facets/install_generator.rb", :blue
144
+ say "• Templates: lib/generators/propel_facets/templates/", :blue
145
+ say "• Modify any part of the system - it's all yours now!", :cyan
146
+
147
+ say ""
148
+ say "šŸ“– See doc/json_facet.md for complete documentation.", :blue
149
+ say "āš™ļø See config/initializers/propel_facets.rb for all options.", :blue
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ # PropelFacets Configuration
4
+ # This file was generated by: rails generate propel_facets:install
5
+ #
6
+ # PropelFacets provides a flexible system for defining different JSON
7
+ # representations of models and connecting them to controller actions.
8
+
9
+ require "rails"
10
+
11
+ module PropelFacets
12
+ # This module serves as the namespace for the Propel Facets gem.
13
+ # PropelFacets provides a flexible system for defining different JSON
14
+ # representations of models and connecting them to controller actions.
15
+
16
+ class Error < StandardError; end
17
+
18
+ class << self
19
+ def configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def configure
24
+ yield(configuration)
25
+ end
26
+
27
+ def reset_configuration!
28
+ @configuration = nil
29
+ end
30
+ end
31
+
32
+ class Configuration
33
+ # Currently supported options
34
+ attr_accessor :default_facets,
35
+ :strict_mode,
36
+ :default_base_facet,
37
+ :root,
38
+ :api_format
39
+
40
+ # Future features - not yet implemented
41
+ # attr_accessor :auto_include_concerns,
42
+ # :cache_facets,
43
+ # :naming_convention,
44
+ # :include_metadata,
45
+ # :date_format,
46
+ # :null_handling,
47
+ # :nested_depth_limit,
48
+ # :default_excluded_fields,
49
+ # :include_type_information,
50
+ # :pagination_format,
51
+ # :error_format,
52
+ # :performance_logging
53
+
54
+ def initialize
55
+ # Default facets to create on ApplicationRecord
56
+ @default_facets = %w[reference included short details]
57
+
58
+ # Strict mode - raise errors for missing facets instead of warnings
59
+ @strict_mode = false
60
+
61
+ # Default base facet for inheritance
62
+ @default_base_facet = :reference
63
+
64
+ # JSON root structure options: :model, :class, :data, :none
65
+ # :model - { "user": {...} }, :class - { "User": {...} }
66
+ # :data - { "data": {...} }, :none - {...} (flat structure)
67
+ @root = :data
68
+
69
+ # API format standard: :openapi, :rest, :jsonapi, :graphql, :hal, :collection_json, :siren
70
+ @api_format = :rest
71
+
72
+ # Future configuration options (commented out until implemented):
73
+
74
+ # # Field naming convention: :snake_case, :camelCase, :PascalCase, :kebab_case
75
+ # @naming_convention = :snake_case
76
+ #
77
+ # # Include metadata (pagination, counts, etc.) in responses
78
+ # @include_metadata = true
79
+ #
80
+ # # Date/time format: :iso8601, :unix, :rfc3339, or custom strftime format
81
+ # @date_format = :iso8601
82
+ #
83
+ # # Null value handling: :include, :exclude, :empty_string
84
+ # @null_handling = :include
85
+ #
86
+ # # Maximum nesting depth to prevent infinite recursion
87
+ # @nested_depth_limit = 5
88
+ #
89
+ # # Fields to exclude by default from all facets (security sensitive)
90
+ # @default_excluded_fields = %w[password_digest encrypted_password reset_password_token confirmation_token]
91
+ #
92
+ # # Include model type information in JSON output
93
+ # @include_type_information = false
94
+ #
95
+ # # Pagination format: :cursor, :offset, :page, :limit_offset
96
+ # @pagination_format = :page
97
+ #
98
+ # # Error response format: :rails, :jsonapi, :rfc7807, :simple
99
+ # @error_format = :rails
100
+ #
101
+ # # Enable performance logging for facet rendering
102
+ # @performance_logging = Rails.env.development?
103
+ end
104
+ end
105
+ end
106
+
107
+ # Configure PropelFacets with recommended defaults
108
+ PropelFacets.configure do |config|
109
+ # ==========================================
110
+ # CURRENTLY SUPPORTED CONFIGURATION OPTIONS
111
+ # ==========================================
112
+
113
+ # JSON root structure: :model, :class, :data, :none
114
+ # Examples:
115
+ # :data => { "data": { "id": 1, "name": "John" } }
116
+ # :model => { "user": { "id": 1, "name": "John" } }
117
+ # :none => { "id": 1, "name": "John" }
118
+ config.root = :data
119
+
120
+ # API format standard affects response structure and conventions
121
+ # :rest - Standard REST API responses
122
+ # :jsonapi - JSON:API specification (jsonapi.org)
123
+ # :openapi - OpenAPI/Swagger compatible
124
+ # :graphql - GraphQL-like nested structure
125
+ # :hal - Hypertext Application Language
126
+ # :collection_json - Collection+JSON hypermedia type
127
+ # :siren - Siren hypermedia specification
128
+ config.api_format = :rest
129
+
130
+ # Default facets available on all models
131
+ config.default_facets = %w[reference included short details]
132
+
133
+ # Error handling: raise errors (true) or show warnings (false) for missing facets
134
+ config.strict_mode = false
135
+
136
+ # Default base facet for inheritance chains
137
+ config.default_base_facet = :reference
138
+
139
+ # ==========================================
140
+ # FUTURE FEATURES - ROADMAP
141
+ # ==========================================
142
+ # Uncomment and configure as features are implemented
143
+
144
+ # # Automatically include ModelFacet in ApplicationRecord (currently manual via generator)
145
+ # config.auto_include_concerns = true
146
+ #
147
+ # # Cache compiled facets for performance (requires implementation)
148
+ # config.cache_facets = Rails.env.production?
149
+ #
150
+ # # Field naming convention for JSON output
151
+ # # :snake_case - user_name, created_at
152
+ # # :camelCase - userName, createdAt
153
+ # # :PascalCase - UserName, CreatedAt
154
+ # # :kebab_case - user-name, created-at
155
+ # config.naming_convention = :snake_case
156
+ #
157
+ # # Include metadata in responses (pagination info, counts, etc.)
158
+ # config.include_metadata = true
159
+ #
160
+ # # Date/time serialization format
161
+ # # :iso8601 - "2023-12-01T10:30:00Z"
162
+ # # :unix - 1701425400
163
+ # # :rfc3339 - "2023-12-01T10:30:00.000Z"
164
+ # # Custom: - "%Y-%m-%d %H:%M:%S"
165
+ # config.date_format = :iso8601
166
+ #
167
+ # # How to handle null/nil values in JSON
168
+ # # :include - Include null fields: { "name": null }
169
+ # # :exclude - Remove null fields: { }
170
+ # # :empty_string - Convert to empty: { "name": "" }
171
+ # config.null_handling = :include
172
+ #
173
+ # # Security: Fields to exclude from all facets by default
174
+ # config.default_excluded_fields = %w[
175
+ # password_digest encrypted_password password_hash
176
+ # reset_password_token confirmation_token unlock_token
177
+ # otp_secret_key encrypted_otp_secret
178
+ # ]
179
+ #
180
+ # # Performance and safety limits
181
+ # config.nested_depth_limit = 5
182
+ # config.performance_logging = Rails.env.development?
183
+ #
184
+ # # API response formats
185
+ # config.pagination_format = :page # :cursor, :offset, :page, :limit_offset
186
+ # config.error_format = :rails # :rails, :jsonapi, :rfc7807, :simple
187
+ # config.include_type_information = false # Include model class name in JSON
188
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Provide means to connect a controller to a JSON facetable model.
5
+ #
6
+ # Usage:
7
+ #
8
+ # class MyController < ApiController
9
+ # connect_facet :short, actions: [:index]
10
+ # connect_facet :full, actions: [:show, :update, :create]
11
+ # end
12
+ #
13
+ module FacetRenderer
14
+ extend ActiveSupport::Concern
15
+
16
+ class_methods do
17
+ attr_accessor :connected_facets
18
+
19
+ def inherited(subclass)
20
+ super(subclass)
21
+ subclass.connected_facets = @connected_facets.dup
22
+ end
23
+
24
+ # Directive to connect a facet
25
+ def connect_facet(facet, actions: nil)
26
+ @connected_facets = {} if @connected_facets.nil?
27
+ actual_actions = if actions.nil?
28
+ [:index, :create, :show, :update]
29
+ else
30
+ [actions].flatten
31
+ end
32
+ actual_actions.each do |this_action|
33
+ @connected_facets[this_action] = facet
34
+ end
35
+ end
36
+
37
+ # Gets facet for action name or nil
38
+ def action_facet(action)
39
+ @connected_facets = {} if @connected_facets.nil?
40
+ @connected_facets[action]
41
+ end
42
+ end
43
+
44
+ # To be used by controller to return params with visible and included fields
45
+ def resource_json(resource)
46
+ facet = self.class.action_facet(action_name.to_sym)
47
+ result = resource.as_json(
48
+ facet: [facet, :index, :default],
49
+ missing: :noaction
50
+ )
51
+
52
+ # get attachment url in single resource
53
+ if !resource.respond_to?('length') && resource.class.respond_to?('attachment_reflections')
54
+ set_attachments(result, resource)
55
+ else
56
+ result
57
+ end
58
+ end
59
+
60
+ # set attachments to result json
61
+ def set_attachments(result, resource)
62
+ attachment_fields = {}
63
+ resource.class.attachment_reflections.keys.each do |name|
64
+ begin
65
+ attachment = resource.send(name.to_sym)
66
+ if attachment.is_a? ActiveStorage::Attached::Many
67
+ # Note: This is configured to return a maximum of 10 files.
68
+ # To retrieve all files within a specific parent model, utilize the 'Attachment' class.
69
+ attachment_fields[name.to_sym] = attachment.take(10).map {|a| url_for(a) if attachment.attached?}.compact
70
+ else
71
+ attachment_fields[name.to_sym] = url_for(attachment) if attachment.attached?
72
+ end
73
+ rescue StandardError => e
74
+ Rails.logger.error "Failed to generate URL for attachment #{name}: #{e.message}"
75
+ next
76
+ end
77
+ end
78
+
79
+ result&.merge(attachment_fields)
80
+ end
81
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ # Helper for defining strong parameters in this application
6
+ #
7
+ # By including this into your controller, you will be allowed to define
8
+ # allowed parameters by calling `permitted_params` and passing a list
9
+ # of field names. This works specially for our application controllers
10
+ # structure where fields are found within `data` JSON property at the
11
+ # very root of a submitted payload
12
+ #
13
+ module StrongParamsHelper
14
+ extend ActiveSupport::Concern
15
+
16
+ class_methods do
17
+ attr_accessor :all_permitted_params, :all_permitted_unknown_params
18
+
19
+ # Directive to define permitted params list
20
+ def permitted_params(*names)
21
+ names.each do |name|
22
+ if @all_permitted_params.nil?
23
+ @all_permitted_params = [name]
24
+ elsif @all_permitted_params.include? name
25
+ warn "[ALREADY PERMITTED] `#{name}` was already permitted"
26
+ else
27
+ @all_permitted_params.push name
28
+ end
29
+ end
30
+ end
31
+
32
+ def permitted_unknown_params(*names)
33
+ names.each do |name|
34
+ if @all_permitted_unknown_params.nil?
35
+ @all_permitted_unknown_params = [name]
36
+ elsif @all_permitted_unknown_params.include? name
37
+ warn "[ALREADY PERMITTED] `#{name}` was already permitted"
38
+ else
39
+ @all_permitted_unknown_params.push name
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ # To be used by controller to parse params as strong params
46
+ def resource_params
47
+ permitted = self.class.all_permitted_params
48
+
49
+ api_params = ApiParams.new(params, request)
50
+ permitted_params = api_params.permit(permitted)
51
+
52
+ if self.class.all_permitted_unknown_params.present?
53
+ self.class.all_permitted_unknown_params.each do |key|
54
+ next unless (params.include?(:data) && !params[:data][key].nil?) || !params[key].nil?
55
+ permitted_params = permitted_params.merge({ key => api_params.force_permit(key) })
56
+ end
57
+ end
58
+
59
+ permitted_params
60
+ end
61
+ end
@@ -0,0 +1,78 @@
1
+ # JSON Facet System
2
+
3
+ JSON Facets provide a flexible way to define different JSON representations of your ActiveRecord models and automatically connect them to controller actions.
4
+
5
+ ## Model Usage
6
+
7
+ Define facets in your models by including the `ModelFacet` concern:
8
+
9
+ ```ruby
10
+ class User < ApplicationRecord
11
+ # Basic facet with specific fields
12
+ json_facet :short, fields: [:name, :email, :created_at]
13
+
14
+ # Extended facet building on another
15
+ json_facet :details, base: :short,
16
+ fields: [:updated_at],
17
+ methods: [:full_name],
18
+ include: [:posts]
19
+
20
+ # With associations
21
+ json_facet :with_posts, base: :short, include: [:posts]
22
+ end
23
+ ```
24
+
25
+ ## Controller Usage
26
+
27
+ Connect facets to controller actions:
28
+
29
+ ```ruby
30
+ class UsersController < ApplicationController
31
+ include FacetRenderer
32
+ include StrongParamsHelper
33
+
34
+ connect_facet :short, actions: [:index]
35
+ connect_facet :details, actions: [:show, :update]
36
+
37
+ permitted_params :name, :email, :role
38
+
39
+ def index
40
+ users = User.all
41
+ render json: { data: users.map { |user| resource_json(user) } }
42
+ end
43
+
44
+ def show
45
+ user = User.find(params[:id])
46
+ render json: { data: resource_json(user) }
47
+ end
48
+ end
49
+ ```
50
+
51
+ ## Parameter Handling
52
+
53
+ For complex JSON parameters:
54
+
55
+ ```ruby
56
+ class ProductsController < ApplicationController
57
+ include StrongParamsHelper
58
+
59
+ permitted_params :name, :price, :category
60
+ permitted_unknown_params :metadata, :settings # For dynamic JSON structures
61
+ end
62
+ ```
63
+
64
+ ## Facet Options
65
+
66
+ - `fields`: Array of model attributes to include
67
+ - `methods`: Array of model methods to call and include
68
+ - `include`: Array of associations to include
69
+ - `include_as`: How to render included associations (uses their facets)
70
+ - `base`: Extend another facet definition
71
+
72
+ ## Default Facets
73
+
74
+ All models inherit these default facets from `ApplicationRecord`:
75
+ - `:reference` - Basic id and type
76
+ - `:included` - Extends reference
77
+ - `:short` - General purpose summary view
78
+ - `:details` - Comprehensive view with associations
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Base class for all errors raised by this application
5
+ #
6
+ class ApplicationError < StandardError
7
+ def initialize(msg = 'Application error')
8
+ super
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Error thrown when a facet cannot be found
5
+ #
6
+ # This is only thrown when you pass `{ missing: :error }` as part of
7
+ # `as_json` options.
8
+ #
9
+ class MissingFacet < ApplicationError
10
+ def initialize(msg = 'Facet not found to render JSON')
11
+ super
12
+ end
13
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Helps manipulating client parameters for our API
5
+ #
6
+ class ApiParams
7
+ def initialize(params, request = nil)
8
+ @params = if params.is_a? ActionController::Parameters
9
+ params
10
+ else
11
+ ActionController::Parameters.new(params)
12
+ end
13
+ @user_agent = request&.user_agent
14
+ end
15
+
16
+ def permit(params)
17
+ if !@params.include?(:data) && command_line_agent
18
+ @params.permit params
19
+ else
20
+ @params.require(:data).permit(params)
21
+ end
22
+ end
23
+
24
+ def force_permit(key)
25
+ if !@params.include?(:data) && command_line_agent
26
+ @params[key].permit!
27
+ else
28
+ if @params[:data][key].is_a?(Array) # for nested attributes
29
+ @params[:data][key].each do |field|
30
+ field.permit!
31
+ end
32
+ else
33
+ @params[:data][key].permit!
34
+ end
35
+ end
36
+ end
37
+
38
+ CMDLINE_AGENTS = %w[httpie curl].freeze
39
+
40
+ def command_line_agent
41
+ agent = @user_agent&.downcase
42
+ CMDLINE_AGENTS.any? ->(prefix) { agent&.start_with? prefix }
43
+ end
44
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ include ModelFacet
5
+
6
+ primary_abstract_class
7
+
8
+ # Default JSON facets for all models
9
+ json_facet :reference, fields: [:id, :type]
10
+ json_facet :included, base: :reference
11
+ json_facet :short, base: :included, include_as: :reference
12
+ json_facet :details, base: :short, include_as: :included
13
+ end