propel_api 0.2.1 → 0.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +239 -4
- data/lib/generators/propel_api/core/named_base.rb +92 -1
- data/lib/generators/propel_api/core/relationship_inferrer.rb +4 -11
- data/lib/generators/propel_api/install/install_generator.rb +2 -2
- data/lib/generators/propel_api/resource/resource_generator.rb +205 -63
- data/lib/generators/propel_api/templates/config/propel_api.rb.tt +26 -1
- data/lib/generators/propel_api/templates/controllers/api_controller_graphiti.rb +2 -2
- data/lib/generators/propel_api/templates/controllers/api_controller_propel_facets.rb +20 -10
- data/lib/generators/propel_api/templates/controllers/example_controller.rb.tt +2 -2
- data/lib/generators/propel_api/templates/scaffold/facet_controller_template.rb.tt +34 -9
- data/lib/generators/propel_api/templates/scaffold/facet_model_template.rb.tt +17 -8
- data/lib/generators/propel_api/templates/scaffold/graphiti_controller_template.rb.tt +1 -1
- data/lib/generators/propel_api/templates/scaffold/graphiti_resource_template.rb.tt +2 -2
- data/lib/generators/propel_api/templates/seeds/seeds_template.rb.tt +62 -14
- data/lib/generators/propel_api/templates/tests/controller_test_template.rb.tt +34 -6
- data/lib/generators/propel_api/templates/tests/fixtures_template.yml.tt +58 -15
- data/lib/generators/propel_api/templates/tests/integration_test_template.rb.tt +108 -36
- data/lib/generators/propel_api/templates/tests/model_test_template.rb.tt +20 -0
- data/lib/propel_api.rb +1 -1
- metadata +22 -2
@@ -4,45 +4,77 @@ require_relative '../core/named_base'
|
|
4
4
|
module PropelApi
|
5
5
|
class ResourceGenerator < PropelApi::NamedBase
|
6
6
|
source_root File.expand_path("../templates", __dir__)
|
7
|
+
|
7
8
|
|
8
9
|
desc <<~DESC
|
9
10
|
Generate a complete API resource with model, migration, controller, and routes
|
10
11
|
|
11
|
-
|
12
|
+
BASIC USAGE:
|
13
|
+
rails generate propel_api:resource Product name:string price:decimal
|
14
|
+
→ Creates model, migration, controller, routes, tests, and fixtures
|
15
|
+
→ Requires confirmation if multi-tenancy attributes are missing
|
12
16
|
|
13
|
-
|
14
|
-
rails generate propel_api
|
15
|
-
→
|
16
|
-
→
|
17
|
+
MULTI-TENANT RESOURCES (RECOMMENDED):
|
18
|
+
rails generate propel_api:resource Product name:string organization:references agency:references
|
19
|
+
→ Includes multi-tenancy associations for proper data isolation
|
20
|
+
→ organization:references (required) - isolates data by organization
|
21
|
+
→ agency:references (optional) - sub-organization grouping
|
17
22
|
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
23
|
+
POLYMORPHIC ASSOCIATIONS:
|
24
|
+
rails generate propel_api:resource Comment content:text commentable:polymorphic
|
25
|
+
→ Comment belongs_to :commentable, polymorphic: true
|
26
|
+
→ Auto-discovers available parent models for fixtures/seeds
|
27
|
+
→ No quotes needed! (also supports commentable:references:polymorphic)
|
28
|
+
|
29
|
+
rails generate propel_api:resource Comment content:text commentable:polymorphic --parents commentable:Post,Photo,Video
|
30
|
+
→ Generates fixtures and seeds with specified polymorphic parent types
|
31
|
+
→ Ensures realistic test data and development seeds
|
32
|
+
|
33
|
+
LEGACY SYNTAX (still supported):
|
34
|
+
rails generate propel_api:resource Comment content:text "commentable:references{polymorphic}"
|
24
35
|
|
25
|
-
|
36
|
+
SKIP CONFIRMATIONS:
|
37
|
+
rails generate propel_api:resource Tag name:string --skip-tenancy
|
38
|
+
→ No confirmation prompts about missing multi-tenancy attributes
|
26
39
|
|
27
|
-
|
28
|
-
rails generate propel_api Article title:string user:references --skip-associations
|
40
|
+
SKIP ASSOCIATIONS:
|
41
|
+
rails generate propel_api:resource Article title:string user:references --skip-associations
|
29
42
|
→ No associations generated (manual setup required)
|
30
43
|
DESC
|
31
44
|
|
32
45
|
argument :attributes, type: :array, default: [], banner: "field:type field:type"
|
33
46
|
|
47
|
+
def initialize(args = [], local_options = {}, config = {})
|
48
|
+
# Process attributes to handle our custom polymorphic syntax
|
49
|
+
processed_args = args.dup
|
50
|
+
if processed_args.length > 1
|
51
|
+
# Convert user-friendly polymorphic syntax to Rails format
|
52
|
+
processed_args[1..-1] = processed_args[1..-1].map do |attr|
|
53
|
+
convert_polymorphic_syntax(attr)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
super(processed_args, local_options, config)
|
58
|
+
end
|
59
|
+
|
34
60
|
class_option :skip_associations,
|
35
61
|
type: :boolean,
|
36
62
|
desc: "Skip automatic association generation"
|
37
63
|
|
38
64
|
class_option :skip_tenancy,
|
39
65
|
type: :boolean,
|
40
|
-
|
66
|
+
default: false,
|
67
|
+
desc: "Skip warnings about missing multi-tenancy attributes (organization/agency)"
|
41
68
|
|
42
69
|
class_option :with_comments,
|
43
70
|
type: :boolean,
|
44
71
|
default: false,
|
45
72
|
desc: "Include usage comments in generated controller"
|
73
|
+
|
74
|
+
class_option :parents,
|
75
|
+
type: :hash,
|
76
|
+
default: {},
|
77
|
+
desc: "Specify parent types for polymorphic associations (e.g., --parents commentable:Post,Photo,Video)"
|
46
78
|
|
47
79
|
def create_migration
|
48
80
|
initialize_propel_api_settings
|
@@ -90,7 +122,20 @@ module PropelApi
|
|
90
122
|
say "No migration file found for #{table_name}", :yellow
|
91
123
|
end
|
92
124
|
else
|
93
|
-
|
125
|
+
# Check for tenancy and warn if missing (unless warnings are disabled)
|
126
|
+
check_tenancy_attributes_and_warn unless options[:skip_tenancy]
|
127
|
+
|
128
|
+
# Build migration arguments from processed attributes
|
129
|
+
# This ensures our custom polymorphic syntax is converted to Rails format
|
130
|
+
migration_args = attributes.map do |attr|
|
131
|
+
if attr.type == :references && attr.respond_to?(:polymorphic?) && attr.polymorphic?
|
132
|
+
"#{attr.name}:references{polymorphic}"
|
133
|
+
else
|
134
|
+
"#{attr.name}:#{attr.type}"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
generate :migration, "create_#{table_name}", *migration_args
|
94
139
|
# Post-process migration to make non-organization references nullable
|
95
140
|
update_migration_constraints
|
96
141
|
end
|
@@ -256,6 +301,98 @@ module PropelApi
|
|
256
301
|
@relationship_inferrer
|
257
302
|
end
|
258
303
|
|
304
|
+
def check_tenancy_attributes_and_warn
|
305
|
+
# Check configuration first - this enforces organizational standards
|
306
|
+
return unless should_check_tenancy_by_config?
|
307
|
+
|
308
|
+
# Analyze what's missing based on configuration
|
309
|
+
missing_attributes = analyze_missing_tenancy_attributes
|
310
|
+
|
311
|
+
# Only proceed if there are missing attributes
|
312
|
+
return if missing_attributes.empty?
|
313
|
+
|
314
|
+
# Show warning about missing tenancy attributes
|
315
|
+
say "\n" + "="*80, :yellow
|
316
|
+
say "⚠️ MULTI-TENANCY CONFIRMATION REQUIRED", :yellow
|
317
|
+
say "="*80, :yellow
|
318
|
+
|
319
|
+
# Show what's missing based on configuration
|
320
|
+
say "\n📋 This resource is missing required tenancy attributes:", :red
|
321
|
+
missing_attributes.each do |attr|
|
322
|
+
say " • #{attr}:references (required by system policy)", :red
|
323
|
+
end
|
324
|
+
|
325
|
+
# Show recommended command with missing attributes
|
326
|
+
missing_attrs = missing_attributes.map { |attr| "#{attr}:references" }.join(' ')
|
327
|
+
say "\n💡 Recommended command:", :green
|
328
|
+
say " rails g propel_api:resource #{class_name} #{formatted_attributes} #{missing_attrs}", :green
|
329
|
+
|
330
|
+
say "\n" + "="*80, :yellow
|
331
|
+
|
332
|
+
# Require confirmation to proceed without tenancy
|
333
|
+
unless yes?("\n🚨 Do you want to continue WITHOUT required tenancy attributes? (y/N)")
|
334
|
+
say "\n✋ Generation cancelled. Please add the required tenancy attributes.", :red
|
335
|
+
say "💡 To skip this confirmation in the future: --skip-tenancy", :blue
|
336
|
+
exit 1
|
337
|
+
end
|
338
|
+
|
339
|
+
say "\n🚨 Proceeding without required tenancy! This may violate system policies.", :yellow
|
340
|
+
|
341
|
+
say ""
|
342
|
+
end
|
343
|
+
|
344
|
+
def should_check_tenancy_by_config?
|
345
|
+
# First check: Is tenancy enforcement enabled in configuration?
|
346
|
+
return false unless PropelApi.configuration.enforce_tenancy
|
347
|
+
|
348
|
+
# Second check: Should this specific model be checked?
|
349
|
+
return should_have_tenancy_by_heuristics?
|
350
|
+
end
|
351
|
+
|
352
|
+
def should_have_tenancy_by_heuristics?
|
353
|
+
# Skip tenancy warnings for certain model types that typically don't need it
|
354
|
+
# This provides a fallback when configuration doesn't specify exceptions
|
355
|
+
skip_tenancy_models = %w[
|
356
|
+
Organization Agency User
|
357
|
+
]
|
358
|
+
|
359
|
+
# Skip if this is one of the models that doesn't typically need tenancy
|
360
|
+
return false if skip_tenancy_models.include?(class_name)
|
361
|
+
|
362
|
+
# Skip if this looks like a join table or through table
|
363
|
+
return false if table_name.include?('_') && table_name.split('_').length == 2
|
364
|
+
|
365
|
+
# Skip if this is clearly a polymorphic join (like comments, attachments, etc.)
|
366
|
+
polymorphic_attributes = attributes.select { |attr| attr.respond_to?(:polymorphic?) && attr.polymorphic? }
|
367
|
+
return false if polymorphic_attributes.any? && attributes.length <= 3
|
368
|
+
|
369
|
+
# Otherwise, assume it needs tenancy
|
370
|
+
true
|
371
|
+
end
|
372
|
+
|
373
|
+
def analyze_missing_tenancy_attributes
|
374
|
+
# Get configured required tenancy attributes
|
375
|
+
required_attrs = PropelApi.configuration.required_tenancy_attributes || []
|
376
|
+
|
377
|
+
# Check which attributes are present in the generator arguments
|
378
|
+
present_attrs = attributes.select { |attr| attr.type == :references }.map { |attr| attr.name.to_sym }
|
379
|
+
|
380
|
+
# Find missing required attributes
|
381
|
+
missing_attrs = required_attrs - present_attrs
|
382
|
+
|
383
|
+
missing_attrs
|
384
|
+
end
|
385
|
+
|
386
|
+
def formatted_attributes
|
387
|
+
attributes.map { |attr|
|
388
|
+
if attr.type == :references && attr.respond_to?(:polymorphic?) && attr.polymorphic?
|
389
|
+
"\"#{attr.name}:references{polymorphic}\""
|
390
|
+
else
|
391
|
+
"#{attr.name}:#{attr.type}"
|
392
|
+
end
|
393
|
+
}.join(' ')
|
394
|
+
end
|
395
|
+
|
259
396
|
def apply_inverse_relationships
|
260
397
|
return unless @relationship_inferrer.should_generate_associations?
|
261
398
|
return if behavior == :revoke
|
@@ -358,55 +495,17 @@ module PropelApi
|
|
358
495
|
file_name.pluralize
|
359
496
|
end
|
360
497
|
|
361
|
-
def migration_attributes
|
362
|
-
# Always include organization (required) and agency (optional) for multi-tenancy
|
363
|
-
# unless --skip-tenancy flag is used
|
364
|
-
if options[:skip_tenancy]
|
365
|
-
# Only include what the developer explicitly specified
|
366
|
-
attributes.map { |attr| "#{attr.name}:#{attr.type}" }
|
367
|
-
else
|
368
|
-
# Include multi-tenancy foundation by default
|
369
|
-
standard_attributes = ["organization:references", "agency:references"]
|
370
|
-
|
371
|
-
# Add user-specified attributes, but skip organization and agency if they're already specified
|
372
|
-
user_attributes = attributes.map { |attr| "#{attr.name}:#{attr.type}" }
|
373
|
-
user_attributes.reject! { |attr| attr.start_with?("organization:") || attr.start_with?("agency:") }
|
374
|
-
|
375
|
-
# Combine standard and user attributes
|
376
|
-
standard_attributes + user_attributes
|
377
|
-
end
|
378
|
-
end
|
379
498
|
|
380
499
|
def permitted_param_names
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
attr.name
|
390
|
-
end
|
391
|
-
end
|
392
|
-
else
|
393
|
-
# Always include organization_id (required) and agency_id (optional) for multi-tenancy
|
394
|
-
standard_params = ["organization_id", "agency_id"]
|
395
|
-
|
396
|
-
# Add user-specified attributes, but skip organization and agency if they're already specified
|
397
|
-
user_params = attributes.map do |attr|
|
398
|
-
# Convert references to foreign key names for permitted params
|
399
|
-
# e.g., organization:references becomes organization_id
|
400
|
-
if attr.type == :references
|
401
|
-
"#{attr.name}_id"
|
402
|
-
else
|
403
|
-
attr.name
|
404
|
-
end
|
500
|
+
# Include exactly what the developer specified
|
501
|
+
attributes.map do |attr|
|
502
|
+
# Convert references to foreign key names for permitted params
|
503
|
+
# e.g., organization:references becomes organization_id
|
504
|
+
if attr.type == :references
|
505
|
+
"#{attr.name}_id"
|
506
|
+
else
|
507
|
+
attr.name
|
405
508
|
end
|
406
|
-
user_params.reject! { |param| param == "organization_id" || param == "agency_id" }
|
407
|
-
|
408
|
-
# Combine standard and user params
|
409
|
-
standard_params + user_params
|
410
509
|
end
|
411
510
|
end
|
412
511
|
|
@@ -488,8 +587,12 @@ module PropelApi
|
|
488
587
|
content = File.read(migration_file)
|
489
588
|
|
490
589
|
# Update references to be nullable except for organization
|
590
|
+
# BUT preserve polymorphic references as-is (they should have polymorphic: true, not foreign_key: true)
|
491
591
|
# organization:references should remain: null: false, foreign_key: true
|
492
|
-
# All other references should become: null: true, foreign_key: true
|
592
|
+
# All other non-polymorphic references should become: null: true, foreign_key: true
|
593
|
+
# Polymorphic references should remain: null: true/false, polymorphic: true
|
594
|
+
|
595
|
+
# First pass: Handle regular references (with foreign_key: true)
|
493
596
|
updated_content = content.gsub(/t\.references :(\w+), null: false, foreign_key: true/) do |match|
|
494
597
|
reference_name = $1
|
495
598
|
if reference_name == 'organization'
|
@@ -499,6 +602,18 @@ module PropelApi
|
|
499
602
|
end
|
500
603
|
end
|
501
604
|
|
605
|
+
# Second pass: Ensure polymorphic references stay polymorphic (don't change polymorphic: true lines)
|
606
|
+
# This is a safety check - Rails should generate polymorphic: true correctly,
|
607
|
+
# but if somehow foreign_key: true got generated for polymorphic fields, fix it
|
608
|
+
polymorphic_attrs = attributes.select { |attr| attr.type == :references && attr.respond_to?(:polymorphic?) && attr.polymorphic? }
|
609
|
+
polymorphic_attrs.each do |attr|
|
610
|
+
# If we find a polymorphic attribute that somehow got foreign_key: true, fix it
|
611
|
+
polymorphic_with_fk_pattern = /t\.references :#{attr.name}, null: (true|false), foreign_key: true/
|
612
|
+
if updated_content.match?(polymorphic_with_fk_pattern)
|
613
|
+
updated_content = updated_content.gsub(polymorphic_with_fk_pattern, "t.references :#{attr.name}, null: true, polymorphic: true")
|
614
|
+
end
|
615
|
+
end
|
616
|
+
|
502
617
|
# Write the updated migration back to file
|
503
618
|
File.write(migration_file, updated_content)
|
504
619
|
end
|
@@ -530,5 +645,32 @@ module PropelApi
|
|
530
645
|
# 2. Newer migrations exist but none have been executed
|
531
646
|
true
|
532
647
|
end
|
648
|
+
|
649
|
+
private
|
650
|
+
|
651
|
+
# Convert user-friendly polymorphic syntax to Rails' expected format
|
652
|
+
def convert_polymorphic_syntax(attr_string)
|
653
|
+
# Handle different polymorphic syntaxes:
|
654
|
+
# commentable:polymorphic -> commentable:references{polymorphic}
|
655
|
+
# commentable:references:polymorphic -> commentable:references{polymorphic}
|
656
|
+
# commentable:references{polymorphic} -> commentable:references{polymorphic} (unchanged)
|
657
|
+
|
658
|
+
return attr_string unless attr_string.include?(':')
|
659
|
+
|
660
|
+
parts = attr_string.split(':')
|
661
|
+
field_name = parts[0]
|
662
|
+
|
663
|
+
# Check for our new polymorphic syntaxes
|
664
|
+
if parts.length == 2 && parts[1] == 'polymorphic'
|
665
|
+
# commentable:polymorphic -> commentable:references{polymorphic}
|
666
|
+
return "#{field_name}:references{polymorphic}"
|
667
|
+
elsif parts.length == 3 && parts[1] == 'references' && parts[2] == 'polymorphic'
|
668
|
+
# commentable:references:polymorphic -> commentable:references{polymorphic}
|
669
|
+
return "#{field_name}:references{polymorphic}"
|
670
|
+
else
|
671
|
+
# Leave unchanged (regular attributes or legacy {polymorphic} syntax)
|
672
|
+
return attr_string
|
673
|
+
end
|
674
|
+
end
|
533
675
|
end
|
534
676
|
end
|
@@ -6,7 +6,7 @@
|
|
6
6
|
|
7
7
|
module PropelApi
|
8
8
|
class Configuration
|
9
|
-
attr_accessor :adapter, :namespace, :version
|
9
|
+
attr_accessor :adapter, :namespace, :version, :enforce_tenancy, :required_tenancy_attributes
|
10
10
|
attr_reader :attribute_filter
|
11
11
|
|
12
12
|
def initialize
|
@@ -15,6 +15,10 @@ module PropelApi
|
|
15
15
|
@namespace = 'api' # Default API namespace
|
16
16
|
@version = 'v1' # Default API version
|
17
17
|
|
18
|
+
# Multi-tenancy configuration - simple boolean
|
19
|
+
@enforce_tenancy = true # true = enforce tenancy (default), false = no checking
|
20
|
+
@required_tenancy_attributes = [:organization, :agency] # Required when enforce_tenancy is true
|
21
|
+
|
18
22
|
# Initialize the configurable attribute filter
|
19
23
|
@attribute_filter = PropelApi::AttributeFilter.new
|
20
24
|
end
|
@@ -132,6 +136,27 @@ PropelApi.configure do |config|
|
|
132
136
|
# API version: 'v1', 'v2', etc. or nil for no versioning
|
133
137
|
config.version = <%= @api_version ? "'#{@api_version}'" : 'nil' %>
|
134
138
|
|
139
|
+
# Multi-tenancy enforcement configuration
|
140
|
+
# Simple boolean: enforce tenancy requirements or not
|
141
|
+
config.enforce_tenancy = true # Default: enforce tenancy (require confirmation if missing)
|
142
|
+
|
143
|
+
# Define which tenancy attributes are required for your system
|
144
|
+
config.required_tenancy_attributes = [:organization, :agency] # Both required by default
|
145
|
+
|
146
|
+
# Examples of different tenancy configurations:
|
147
|
+
#
|
148
|
+
# Organization-only tenancy:
|
149
|
+
# config.required_tenancy_attributes = [:organization]
|
150
|
+
#
|
151
|
+
# Agency-only tenancy:
|
152
|
+
# config.required_tenancy_attributes = [:agency]
|
153
|
+
#
|
154
|
+
# Extended enterprise multi-tenancy:
|
155
|
+
# config.required_tenancy_attributes = [:organization, :agency, :department]
|
156
|
+
#
|
157
|
+
# Single-tenant application (no tenancy checking):
|
158
|
+
# config.enforce_tenancy = false
|
159
|
+
|
135
160
|
# Customize attribute filtering for your project's naming conventions
|
136
161
|
# Examples:
|
137
162
|
#
|
@@ -41,7 +41,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
41
41
|
if resource_instance.save
|
42
42
|
respond_with(resource_instance, status: :created)
|
43
43
|
else
|
44
|
-
respond_with(resource_instance.errors, status: :
|
44
|
+
respond_with(resource_instance.errors, status: :unprocessable_content)
|
45
45
|
end
|
46
46
|
end
|
47
47
|
|
@@ -54,7 +54,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
54
54
|
if resource_instance.update_attributes
|
55
55
|
respond_with(resource_instance)
|
56
56
|
else
|
57
|
-
respond_with(resource_instance.errors, status: :
|
57
|
+
respond_with(resource_instance.errors, status: :unprocessable_content)
|
58
58
|
end
|
59
59
|
end
|
60
60
|
|
@@ -40,7 +40,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
40
40
|
if @resource.save
|
41
41
|
render json: { data: resource_json(@resource) }, status: :created
|
42
42
|
else
|
43
|
-
render json: { errors: @resource.errors }, status: :
|
43
|
+
render json: { errors: @resource.errors }, status: :unprocessable_content
|
44
44
|
end
|
45
45
|
end
|
46
46
|
|
@@ -48,7 +48,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
48
48
|
if @resource.update(resource_params)
|
49
49
|
render json: { data: resource_json(@resource) }
|
50
50
|
else
|
51
|
-
render json: { errors: @resource.errors }, status: :
|
51
|
+
render json: { errors: @resource.errors }, status: :unprocessable_content
|
52
52
|
end
|
53
53
|
end
|
54
54
|
|
@@ -73,6 +73,9 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
73
73
|
|
74
74
|
# Organization scoping method with support for self-signup
|
75
75
|
def for_organization(model_class)
|
76
|
+
# Skip tenancy if controller explicitly opts out
|
77
|
+
return model_class if respond_to?(:skip_tenancy?) && skip_tenancy?
|
78
|
+
|
76
79
|
return render_organization_context_error unless current_organization_id
|
77
80
|
|
78
81
|
# Special case: Organization model should only show user's own organization
|
@@ -80,6 +83,9 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
80
83
|
return model_class.where(id: current_organization_id)
|
81
84
|
end
|
82
85
|
|
86
|
+
# Skip if model doesn't have organization_id column
|
87
|
+
return model_class unless model_class.column_names.include?('organization_id')
|
88
|
+
|
83
89
|
# Start with organization scoping
|
84
90
|
scoped_query = model_class.for_organization(current_organization_id)
|
85
91
|
|
@@ -89,7 +95,10 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
89
95
|
|
90
96
|
# Build parameters for resource creation with security-first flow
|
91
97
|
def build_creation_params
|
92
|
-
|
98
|
+
# Skip organization validation if controller opts out or model doesn't have organization_id
|
99
|
+
unless (respond_to?(:skip_tenancy?) && skip_tenancy?) || !resource_class.column_names.include?('organization_id')
|
100
|
+
return {} unless validate_organization_context
|
101
|
+
end
|
93
102
|
|
94
103
|
params = resource_params
|
95
104
|
|
@@ -141,7 +150,8 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
141
150
|
# FIRST: Security validation of any provided tenancy context (fail-fast)
|
142
151
|
def validate_provided_tenancy_context(params)
|
143
152
|
# If organization_id is provided, verify user has access to it
|
144
|
-
if
|
153
|
+
# Only validate if model actually has organization_id column
|
154
|
+
if params[:organization_id].present? && resource_class.column_names.include?('organization_id')
|
145
155
|
unless params[:organization_id].to_i == current_organization_id
|
146
156
|
render json: {
|
147
157
|
error: 'Unauthorized organization access',
|
@@ -185,7 +195,8 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
185
195
|
# Auto-assign organization_id if missing and not required to be explicit
|
186
196
|
if params[:organization_id].blank? && !require_organization_id?
|
187
197
|
# Special case: Organization model doesn't get organization_id assigned
|
188
|
-
|
198
|
+
# Only assign if model has organization_id column
|
199
|
+
if resource_class != Organization && resource_class.column_names.include?('organization_id')
|
189
200
|
params[:organization_id] = current_organization_id
|
190
201
|
end
|
191
202
|
end
|
@@ -201,8 +212,6 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
201
212
|
params
|
202
213
|
end
|
203
214
|
|
204
|
-
|
205
|
-
|
206
215
|
# Check if agency tenancy is enabled
|
207
216
|
def agency_tenancy_enabled?
|
208
217
|
# Check PropelAuthentication configuration (owns tenancy models)
|
@@ -238,7 +247,8 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
238
247
|
errors = {}
|
239
248
|
|
240
249
|
# Validate organization_id is present (after security check and auto-assignment)
|
241
|
-
if
|
250
|
+
# Only validate if model actually has organization_id column
|
251
|
+
if resource_class.column_names.include?('organization_id') && params[:organization_id].blank?
|
242
252
|
if require_organization_id?
|
243
253
|
errors[:organization_id] = ["is required - set require_organization_id = false to enable auto-assignment"]
|
244
254
|
else
|
@@ -262,7 +272,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
262
272
|
|
263
273
|
# Return validation errors if any
|
264
274
|
if errors.any?
|
265
|
-
render json: { errors: errors }, status: :
|
275
|
+
render json: { errors: errors }, status: :unprocessable_content
|
266
276
|
return {}
|
267
277
|
end
|
268
278
|
|
@@ -281,7 +291,7 @@ class <%= api_controller_class_name %> < ApplicationController
|
|
281
291
|
def pagy_metadata(pagy)
|
282
292
|
{
|
283
293
|
current_page: pagy.page,
|
284
|
-
per_page: pagy.
|
294
|
+
per_page: pagy.limit,
|
285
295
|
total_pages: pagy.pages,
|
286
296
|
total_count: pagy.count,
|
287
297
|
next_page: pagy.next,
|
@@ -42,7 +42,7 @@ module Api
|
|
42
42
|
# if @resource.save
|
43
43
|
# render json: { data: resource_json(@resource) }, status: :created
|
44
44
|
# else
|
45
|
-
# render json: { errors: @resource.errors }, status: :
|
45
|
+
# render json: { errors: @resource.errors }, status: :unprocessable_content
|
46
46
|
# end
|
47
47
|
# end
|
48
48
|
end
|
@@ -82,7 +82,7 @@ module Api
|
|
82
82
|
# if resource_instance.save
|
83
83
|
# respond_with(resource_instance, status: :created)
|
84
84
|
# else
|
85
|
-
# respond_with(resource_instance.errors, status: :
|
85
|
+
# respond_with(resource_instance.errors, status: :unprocessable_content)
|
86
86
|
# end
|
87
87
|
# end
|
88
88
|
end
|
@@ -10,15 +10,40 @@ class <%= controller_class_name_with_namespace %>Controller < <%= api_controller
|
|
10
10
|
# Define which parameters are allowed for <%= class_name %> creation/updates
|
11
11
|
# This uses the StrongParamsHelper concern from the base controller
|
12
12
|
<% end -%>
|
13
|
-
permitted_params
|
14
|
-
#
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
13
|
+
permitted_params <%
|
14
|
+
# Generate permitted parameters using shared polymorphic support
|
15
|
+
param_list = []
|
16
|
+
|
17
|
+
# Add attribute-based params using polymorphic support
|
18
|
+
attributes.each do |attr|
|
19
|
+
if attr.type == :json
|
20
|
+
param_list << "#{attr.name}: {}"
|
21
|
+
elsif attr.type == :references
|
22
|
+
# Use Rails' built-in polymorphic detection
|
23
|
+
if attr.respond_to?(:polymorphic?) && attr.polymorphic?
|
24
|
+
param_list << ":#{attr.name}_id"
|
25
|
+
param_list << ":#{attr.name}_type"
|
26
|
+
else
|
27
|
+
param_list << ":#{attr.name}_id"
|
28
|
+
end
|
29
|
+
else
|
30
|
+
param_list << ":#{attr.name}"
|
31
|
+
end
|
20
32
|
end
|
21
|
-
|
33
|
+
|
34
|
+
# Add multi-tenancy params unless skipped
|
35
|
+
if has_organization_reference?
|
36
|
+
param_list += [":organization_id"]
|
37
|
+
end
|
38
|
+
|
39
|
+
if has_agency_reference?
|
40
|
+
param_list += [":agency_id"]
|
41
|
+
end
|
42
|
+
|
43
|
+
# Remove organization/agency duplicates if they were user-specified
|
44
|
+
param_list.uniq!
|
45
|
+
|
46
|
+
-%><%= param_list.join(', ') %>
|
22
47
|
|
23
48
|
<% if options[:with_comments] -%>
|
24
49
|
# Connect facets to actions - customize as needed for your <%= class_name.downcase %> model
|
@@ -84,7 +109,7 @@ class <%= controller_class_name_with_namespace %>Controller < <%= api_controller
|
|
84
109
|
# if @resource.save
|
85
110
|
# render json: { data: resource_json(@resource) }, status: :created
|
86
111
|
# else
|
87
|
-
# render json: { errors: @resource.errors }, status: :
|
112
|
+
# render json: { errors: @resource.errors }, status: :unprocessable_content
|
88
113
|
# end
|
89
114
|
# end
|
90
115
|
#
|
@@ -3,7 +3,7 @@
|
|
3
3
|
class <%= class_name %> < ApplicationRecord
|
4
4
|
|
5
5
|
# Validations
|
6
|
-
<%
|
6
|
+
<% if has_organization_reference? -%>
|
7
7
|
# Multi-tenancy: Organization is always required
|
8
8
|
validates :organization, presence: true
|
9
9
|
|
@@ -45,24 +45,28 @@ class <%= class_name %> < ApplicationRecord
|
|
45
45
|
<% end -%>
|
46
46
|
|
47
47
|
# Associations
|
48
|
-
<%
|
48
|
+
<% if has_organization_reference? -%>
|
49
49
|
# Multi-tenancy: Always include organization and agency for data isolation
|
50
50
|
# User/Agent associations are only added when explicitly specified as generator arguments
|
51
51
|
belongs_to :organization
|
52
|
+
<% end -%>
|
53
|
+
<% if has_agency_reference? -%>
|
52
54
|
belongs_to :agency
|
55
|
+
<% end -%>
|
56
|
+
<% if has_organization_reference? || has_agency_reference? -%>
|
53
57
|
|
54
58
|
<% end -%>
|
55
59
|
<% if defined?(relationship_inferrer) && relationship_inferrer.should_generate_associations? -%>
|
56
60
|
<% relationship_inferrer.belongs_to_relationships.each do |relationship| -%>
|
57
61
|
<% # Skip organization and agency if tenancy is included (they're already added above) -%>
|
58
|
-
<% unless
|
62
|
+
<% unless (has_organization_reference? && relationship.include?('organization')) || (has_agency_reference? && relationship.include?('agency')) -%>
|
59
63
|
<%= relationship %>
|
60
64
|
<% end -%>
|
61
65
|
<% end -%>
|
62
66
|
<% elsif !options[:skip_associations] -%>
|
63
67
|
<% attributes.select { |attr| attr.type == :references }.each do |attribute| -%>
|
64
68
|
<% # Skip organization and agency if tenancy is included (they're already added above) -%>
|
65
|
-
<% unless
|
69
|
+
<% unless (has_organization_reference? && attribute.name == 'organization') || (has_agency_reference? && attribute.name == 'agency') -%>
|
66
70
|
<% if attribute.name == 'organization' -%>
|
67
71
|
belongs_to :organization
|
68
72
|
<% else -%>
|
@@ -134,9 +138,11 @@ json_facet :details, fields: [:id<%
|
|
134
138
|
all_fields << "#{reference.name}_id".to_sym
|
135
139
|
end
|
136
140
|
|
137
|
-
# Add tenancy references if
|
138
|
-
|
141
|
+
# Add tenancy references if present
|
142
|
+
if has_organization_reference?
|
139
143
|
all_fields << :organization_id
|
144
|
+
end
|
145
|
+
if has_agency_reference?
|
140
146
|
all_fields << :agency_id
|
141
147
|
end
|
142
148
|
|
@@ -145,8 +151,11 @@ json_facet :details, fields: [:id<%
|
|
145
151
|
|
146
152
|
# Generate include for nested objects (reference associations)
|
147
153
|
reference_associations = detail_attributes.select { |attr| attr.type == :references }.map(&:name)
|
148
|
-
|
149
|
-
reference_associations += %w[organization
|
154
|
+
if has_organization_reference?
|
155
|
+
reference_associations += %w[organization]
|
156
|
+
end
|
157
|
+
if has_agency_reference?
|
158
|
+
reference_associations += %w[agency]
|
150
159
|
end
|
151
160
|
reference_associations.uniq!
|
152
161
|
|
@@ -55,7 +55,7 @@ class <%= controller_class_name_with_namespace %>Controller < <%= api_controller
|
|
55
55
|
# if resource_instance.save
|
56
56
|
# respond_with(resource_instance, status: :created)
|
57
57
|
# else
|
58
|
-
# respond_with(resource_instance.errors, status: :
|
58
|
+
# respond_with(resource_instance.errors, status: :unprocessable_content)
|
59
59
|
# end
|
60
60
|
# end
|
61
61
|
#
|