rails_ai_admin 0.1.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 +7 -0
- data/CHANGELOG.md +458 -0
- data/LICENSE +21 -0
- data/README.md +292 -0
- data/SECURITY.md +14 -0
- data/THIRD_PARTY_NOTICES.md +19 -0
- data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
- data/app/assets/builds/rails_ai_admin.css +345 -0
- data/app/assets/builds/rails_ai_admin.js +33 -0
- data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
- data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
- data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
- data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
- data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
- data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
- data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
- data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
- data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
- data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
- data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
- data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
- data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
- data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
- data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
- data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
- data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
- data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
- data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
- data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
- data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
- data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
- data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
- data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
- data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
- data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
- data/app/models/rails_ai_admin/application_record.rb +8 -0
- data/app/models/rails_ai_admin/audit_event.rb +12 -0
- data/app/models/rails_ai_admin/batch_item.rb +8 -0
- data/app/models/rails_ai_admin/operation_run.rb +20 -0
- data/app/models/rails_ai_admin/selection.rb +9 -0
- data/app/models/rails_ai_admin/selection_item.rb +9 -0
- data/app/models/rails_ai_admin/work_item.rb +18 -0
- data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
- data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
- data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
- data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
- data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
- data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
- data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
- data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
- data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
- data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
- data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
- data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
- data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
- data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
- data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
- data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
- data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
- data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
- data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
- data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
- data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
- data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
- data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
- data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
- data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
- data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
- data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
- data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
- data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
- data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
- data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
- data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
- data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
- data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
- data/app/views/rails_ai_admin/search/index.html.erb +20 -0
- data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
- data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
- data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
- data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
- data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
- data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
- data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
- data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
- data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
- data/config/locales/en.yml +549 -0
- data/config/routes.rb +64 -0
- data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
- data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
- data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
- data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
- data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
- data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
- data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
- data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
- data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
- data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
- data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
- data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
- data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
- data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
- data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
- data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
- data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
- data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
- data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
- data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
- data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
- data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
- data/lib/rails_ai_admin/assistant/records.rb +71 -0
- data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
- data/lib/rails_ai_admin/assistant/tools.rb +477 -0
- data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
- data/lib/rails_ai_admin/assistant.rb +50 -0
- data/lib/rails_ai_admin/authorization/access.rb +296 -0
- data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
- data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
- data/lib/rails_ai_admin/collections/claim.rb +91 -0
- data/lib/rails_ai_admin/collections/contract.rb +113 -0
- data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
- data/lib/rails_ai_admin/collections/queries.rb +79 -0
- data/lib/rails_ai_admin/collections/result.rb +15 -0
- data/lib/rails_ai_admin/collections/runner.rb +208 -0
- data/lib/rails_ai_admin/configuration.rb +178 -0
- data/lib/rails_ai_admin/context.rb +29 -0
- data/lib/rails_ai_admin/doctor.rb +301 -0
- data/lib/rails_ai_admin/engine.rb +24 -0
- data/lib/rails_ai_admin/error.rb +16 -0
- data/lib/rails_ai_admin/field.rb +625 -0
- data/lib/rails_ai_admin/fields/association.rb +188 -0
- data/lib/rails_ai_admin/fields/computation.rb +43 -0
- data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
- data/lib/rails_ai_admin/fields/file_value.rb +23 -0
- data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
- data/lib/rails_ai_admin/fields/projection.rb +78 -0
- data/lib/rails_ai_admin/fields/registry.rb +48 -0
- data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
- data/lib/rails_ai_admin/fields/type.rb +50 -0
- data/lib/rails_ai_admin/forms/plan.rb +90 -0
- data/lib/rails_ai_admin/input_schema.rb +76 -0
- data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
- data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
- data/lib/rails_ai_admin/mcp.rb +130 -0
- data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
- data/lib/rails_ai_admin/navigation/menu.rb +31 -0
- data/lib/rails_ai_admin/navigation/settings.rb +28 -0
- data/lib/rails_ai_admin/operation.rb +120 -0
- data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
- data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
- data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
- data/lib/rails_ai_admin/operations/registry.rb +80 -0
- data/lib/rails_ai_admin/operations/result.rb +21 -0
- data/lib/rails_ai_admin/operations/runner.rb +633 -0
- data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
- data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
- data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
- data/lib/rails_ai_admin/outbox/registry.rb +36 -0
- data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
- data/lib/rails_ai_admin/outbox/writer.rb +43 -0
- data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
- data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
- data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
- data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
- data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
- data/lib/rails_ai_admin/presentation/history.rb +46 -0
- data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
- data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
- data/lib/rails_ai_admin/presentation/status.rb +40 -0
- data/lib/rails_ai_admin/prune.rb +61 -0
- data/lib/rails_ai_admin/queries/list.rb +162 -0
- data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
- data/lib/rails_ai_admin/record_version.rb +40 -0
- data/lib/rails_ai_admin/registry.rb +45 -0
- data/lib/rails_ai_admin/request_log_filter.rb +24 -0
- data/lib/rails_ai_admin/resource.rb +249 -0
- data/lib/rails_ai_admin/resource_identity.rb +15 -0
- data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
- data/lib/rails_ai_admin/selections/builder.rb +162 -0
- data/lib/rails_ai_admin/templates/catalog.rb +84 -0
- data/lib/rails_ai_admin/templates/inspector.rb +94 -0
- data/lib/rails_ai_admin/templates/metadata.rb +48 -0
- data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
- data/lib/rails_ai_admin/test_kit.rb +103 -0
- data/lib/rails_ai_admin/version.rb +5 -0
- data/lib/rails_ai_admin/work.rb +48 -0
- data/lib/rails_ai_admin.rb +112 -0
- data/lib/tasks/rails_ai_admin.rake +58 -0
- metadata +328 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Fields
|
|
5
|
+
# Resolves belongs_to references only through the target resource's current
|
|
6
|
+
# policy relation, record show grant and title. A target that is missing, in
|
|
7
|
+
# another tenant or not shown to this actor is indistinguishable: pages show
|
|
8
|
+
# only the already-readable key, and writes reject it with one message.
|
|
9
|
+
#
|
|
10
|
+
# One instance is a request-local cache. It never survives authentication.
|
|
11
|
+
class Association
|
|
12
|
+
MAX_CHOICES = 100
|
|
13
|
+
MAX_BATCH = 500
|
|
14
|
+
UNAVAILABLE = "is not an available choice"
|
|
15
|
+
INTEGER_KEY = /\A[1-9]\d{0,18}\z/
|
|
16
|
+
# visible is true only for a target in the current relation with a show grant.
|
|
17
|
+
Reference = Data.define(:id, :label, :resource_key, :visible)
|
|
18
|
+
# complete is false when the picker would exceed MAX_CHOICES or the actor
|
|
19
|
+
# cannot list the target resource; forms then accept a typed key instead.
|
|
20
|
+
Choices = Data.define(:options, :complete)
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def target(field, registry: RailsAiAdmin.registry)
|
|
24
|
+
raise ConfigurationError, "Expected a belongs_to or references field" unless field.is_a?(Field) && Field::REFERENCE_TYPES.include?(field.scalar_type)
|
|
25
|
+
key = field.options.fetch(:resource).to_s
|
|
26
|
+
registry.resources.find { |resource| resource.key.to_s == key } ||
|
|
27
|
+
raise(ConfigurationError, "belongs_to #{field.name} names an unregistered resource")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The declaration must match a non-polymorphic model belongs_to whose
|
|
31
|
+
# foreign key is the declared column and whose primary key is the target
|
|
32
|
+
# resource's single primary key. Nothing here queries domain rows.
|
|
33
|
+
def validate!(resource, field, registry: RailsAiAdmin.registry)
|
|
34
|
+
target = target(field, registry: registry)
|
|
35
|
+
return validate_collection!(resource, field, target) if field.scalar_type == :references
|
|
36
|
+
target_model = target.model_class
|
|
37
|
+
reflection = resource.model_class.reflect_on_association(field.name)
|
|
38
|
+
valid = reflection && reflection.macro == :belongs_to && !reflection.polymorphic? &&
|
|
39
|
+
reflection.foreign_key.to_s == field.column && target_model.primary_key.is_a?(String) &&
|
|
40
|
+
reflection.klass == target_model && reflection.association_primary_key.to_s == target_model.primary_key
|
|
41
|
+
unless valid
|
|
42
|
+
raise ConfigurationError, "belongs_to #{field.name} must match a non-polymorphic model association on #{field.column} to the #{target.key} primary key"
|
|
43
|
+
end
|
|
44
|
+
target
|
|
45
|
+
rescue NameError, ArgumentError
|
|
46
|
+
raise ConfigurationError, "belongs_to #{field.name} could not resolve its model association", cause: nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A references field must match a non-polymorphic has_many (including
|
|
50
|
+
# through) or has_and_belongs_to_many to the target model, whose
|
|
51
|
+
# <singular>_ids writer is the declared foreign_key.
|
|
52
|
+
def validate_collection!(resource, field, target)
|
|
53
|
+
target_model = target.model_class
|
|
54
|
+
reflection = resource.model_class.reflect_on_association(field.name)
|
|
55
|
+
valid = reflection && %i[has_many has_and_belongs_to_many].include?(reflection.macro) && !reflection.polymorphic? &&
|
|
56
|
+
reflection.klass == target_model && target_model.primary_key.is_a?(String) &&
|
|
57
|
+
"#{reflection.name.to_s.singularize}_ids" == field.column && resource.model_class.method_defined?("#{field.column}=")
|
|
58
|
+
unless valid
|
|
59
|
+
raise ConfigurationError, "references #{field.name} must match a has_many or has_and_belongs_to_many association to the #{target.key} model with #{field.column}"
|
|
60
|
+
end
|
|
61
|
+
target
|
|
62
|
+
rescue NameError, ArgumentError
|
|
63
|
+
raise ConfigurationError, "references #{field.name} could not resolve its model association", cause: nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# resource: the declaring resource. Its own fields are checked against the
|
|
68
|
+
# model reflection; operation input fields only need a registered target.
|
|
69
|
+
def initialize(context, resource = nil)
|
|
70
|
+
raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
|
|
71
|
+
@context, @resource = context, resource
|
|
72
|
+
@accesses = {}
|
|
73
|
+
@references = Hash.new { |hash, key| hash[key] = {} }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Loads every unresolved key for field with one bounded target query, so
|
|
77
|
+
# an index page costs one query per association column, not per row.
|
|
78
|
+
def prime(field, ids)
|
|
79
|
+
cache = @references[cache_key(field)]
|
|
80
|
+
pending = ids.compact.map(&:to_s).uniq.reject { |id| cache.key?(id) }
|
|
81
|
+
return self if pending.empty?
|
|
82
|
+
raise ConfigurationError, "Too many references to resolve at once" if pending.size > MAX_BATCH
|
|
83
|
+
access = target_access(field)
|
|
84
|
+
model = access.resource.model_class
|
|
85
|
+
keys = pending.select { |id| key_shape?(model, id) }
|
|
86
|
+
found = keys.empty? ? {} : access.relation.where(model.primary_key => keys).to_a.index_by { |record| record.id.to_s }
|
|
87
|
+
pending.each { |id| cache[id] = build(field, access, found[id], id) }
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def reference(field, id)
|
|
92
|
+
return if id.nil?
|
|
93
|
+
prime(field, [ id ])
|
|
94
|
+
@references[cache_key(field)].fetch(id.to_s)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def admissible?(field, id) = reference(field, id)&.visible == true
|
|
98
|
+
|
|
99
|
+
# Current picker options as [label, key] pairs. Order follows the target
|
|
100
|
+
# title only while that title is a granted sort field, then the key.
|
|
101
|
+
def choices(field)
|
|
102
|
+
access = target_access(field)
|
|
103
|
+
begin
|
|
104
|
+
access.authorize!(:index)
|
|
105
|
+
rescue AccessDenied
|
|
106
|
+
return Choices.new([].freeze, false)
|
|
107
|
+
end
|
|
108
|
+
model = access.resource.model_class
|
|
109
|
+
table = model.arel_table
|
|
110
|
+
title = access.fields(:sort).find { |candidate| candidate.name == access.resource.title }
|
|
111
|
+
relation = access.relation.reorder(nil)
|
|
112
|
+
relation = relation.order(table[title.column].asc) if title
|
|
113
|
+
records = relation.order(table[model.primary_key].asc).limit(MAX_CHOICES + 1).to_a
|
|
114
|
+
cache = @references[cache_key(field)]
|
|
115
|
+
options = records.first(MAX_CHOICES).filter_map do |record|
|
|
116
|
+
reference = cache[record.id.to_s] ||= build(field, access, record, record.id.to_s)
|
|
117
|
+
[ reference.label, reference.id ] if reference.visible
|
|
118
|
+
end
|
|
119
|
+
Choices.new(options.freeze, records.size <= MAX_CHOICES)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Picker search over a target resource, for forms and filters whose
|
|
123
|
+
# choices are incomplete. Results are exactly what the target's own index
|
|
124
|
+
# would show this actor for the same term: its policy relation, its
|
|
125
|
+
# granted search fields, and a show grant per record. A term shaped like
|
|
126
|
+
# the target's primary key also matches that record. Returns [label, key]
|
|
127
|
+
# pairs, at most limit.
|
|
128
|
+
SEARCH_LIMIT = 20
|
|
129
|
+
def self.search(context, target, term, limit: SEARCH_LIMIT)
|
|
130
|
+
raise ConfigurationError, "Invalid picker limit" unless limit.is_a?(Integer) && limit.between?(1, SEARCH_LIMIT)
|
|
131
|
+
access = Authorization::Access.new(context, target)
|
|
132
|
+
access.authorize!(:index)
|
|
133
|
+
term = term.strip if term.is_a?(String) && term.valid_encoding?
|
|
134
|
+
return [] if term.blank?
|
|
135
|
+
model = target.model_class
|
|
136
|
+
exact = access.canonical_id?(term) ? access.relation.where(model.primary_key => term).limit(1).to_a : []
|
|
137
|
+
matches = if access.fields(:search).any?
|
|
138
|
+
Queries::List.new(context, target).call(search: term, scope: Queries::NamedScope::NONE, page_size: limit).records
|
|
139
|
+
else
|
|
140
|
+
# Unsearchable targets still accept an exact key; the term itself
|
|
141
|
+
# is validated the same way the list validates search text.
|
|
142
|
+
raise AccessDenied, "INVALID_QUERY" unless Queries::List::FILTER_VALUE.call(term)
|
|
143
|
+
[]
|
|
144
|
+
end
|
|
145
|
+
(exact + matches).uniq(&:id).first(limit).filter_map do |record|
|
|
146
|
+
access.authorize!(:show, record)
|
|
147
|
+
[ access.record_label(record).freeze, record.id.to_s.freeze ]
|
|
148
|
+
rescue AccessDenied
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def cache_key(field) = field.options.fetch(:resource).to_s
|
|
156
|
+
|
|
157
|
+
def target_access(field)
|
|
158
|
+
@accesses[cache_key(field)] ||= begin
|
|
159
|
+
own = @resource&.fields&.any? { |candidate| candidate.equal?(field) }
|
|
160
|
+
target = own ? self.class.validate!(@resource, field) : self.class.target(field)
|
|
161
|
+
Authorization::Access.new(@context, target)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Integer keys are checked before the query so malformed text never reaches
|
|
166
|
+
# an integer comparison; other key types rely on the column's own cast.
|
|
167
|
+
def key_shape?(model, id)
|
|
168
|
+
return false unless id.match?(Field::REFERENCE)
|
|
169
|
+
column = model.columns_hash[model.primary_key]
|
|
170
|
+
column&.type == :integer ? id.match?(INTEGER_KEY) && Integer(id, 10) <= 2**63 - 1 : true
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def build(field, access, record, id)
|
|
174
|
+
if record
|
|
175
|
+
access.authorize!(:show, record)
|
|
176
|
+
return Reference.new(id.dup.freeze, access.record_label(record).freeze, access.resource.key, true)
|
|
177
|
+
end
|
|
178
|
+
unavailable(field, id)
|
|
179
|
+
rescue AccessDenied
|
|
180
|
+
unavailable(field, id)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def unavailable(field, id)
|
|
184
|
+
Reference.new(id.dup.freeze, "#{self.class.target(field).label.singularize} ##{id}".freeze, nil, false)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Fields
|
|
5
|
+
# Trusted pure host code receives only declared, currently authorized scalar
|
|
6
|
+
# dependencies. It cannot acquire authority from the field declaration.
|
|
7
|
+
class Computation
|
|
8
|
+
extend HookSignatures
|
|
9
|
+
class << self
|
|
10
|
+
def version(value = nil)
|
|
11
|
+
@version = value if value
|
|
12
|
+
@version || "1"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def metadata
|
|
16
|
+
unless name && version.is_a?(String) && version.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/)
|
|
17
|
+
raise ConfigurationError, "Computation needs a named class and bounded version"
|
|
18
|
+
end
|
|
19
|
+
unless respond_to?(:new) && accepts?(method(:new), 0, []) && accepts?(instance_method(:initialize), 0, []) &&
|
|
20
|
+
public_instance_methods.include?(:call) && instance_method(:call).owner != Computation && accepts?(instance_method(:call), 1, %i[field time_zone])
|
|
21
|
+
raise ConfigurationError, "Computation has an incompatible public constructor or call signature"
|
|
22
|
+
end
|
|
23
|
+
{ handler: name, version: version }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(_values, field:, time_zone:)
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Definition = Data.define(:handler_name) do
|
|
32
|
+
def handler_class
|
|
33
|
+
klass = handler_name.constantize
|
|
34
|
+
raise ConfigurationError, "Computed field requires a Computation subclass" unless klass.is_a?(Class) && klass < Computation
|
|
35
|
+
klass
|
|
36
|
+
rescue NameError
|
|
37
|
+
raise ConfigurationError, "Computed field class could not be loaded", cause: nil
|
|
38
|
+
end
|
|
39
|
+
def metadata = handler_class.metadata
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Fields
|
|
5
|
+
module DependencyGraph
|
|
6
|
+
MAX_FIELDS = 128
|
|
7
|
+
MAX_DEPTH = 8
|
|
8
|
+
MAX_DEPENDENCIES = 20
|
|
9
|
+
|
|
10
|
+
def self.validate!(fields)
|
|
11
|
+
return unless fields.any?(&:virtual?)
|
|
12
|
+
raise ConfigurationError, "Computed resource exceeds field bound" if fields.size > MAX_FIELDS
|
|
13
|
+
declared = fields.index_by(&:name)
|
|
14
|
+
depths = {}
|
|
15
|
+
visit = lambda do |field, path|
|
|
16
|
+
return 0 unless field.virtual?
|
|
17
|
+
return depths[field.name] if depths.key?(field.name)
|
|
18
|
+
raise ConfigurationError, "Computed dependency cycle" if path.include?(field.name)
|
|
19
|
+
depth = 1 + (field.dependencies.map do |name|
|
|
20
|
+
dependency = declared.fetch(name) { raise ConfigurationError, "Computed dependency must be a declared field" }
|
|
21
|
+
visit.call(dependency, path + [ field.name ])
|
|
22
|
+
end.max || 0)
|
|
23
|
+
raise ConfigurationError, "Computed dependency depth exceeds bound" if depth > MAX_DEPTH
|
|
24
|
+
depths[field.name] = depth
|
|
25
|
+
end
|
|
26
|
+
fields.select(&:virtual?).each { |field| visit.call(field, []) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.readable(fields)
|
|
30
|
+
granted = fields.index_by(&:name)
|
|
31
|
+
eligible = {}
|
|
32
|
+
visit = lambda do |field, path|
|
|
33
|
+
return eligible[field.name] if eligible.key?(field.name)
|
|
34
|
+
return eligible[field.name] = true unless field.virtual?
|
|
35
|
+
raise ConfigurationError, "Invalid computed dependency graph" if path.include?(field.name) || path.size >= MAX_DEPTH
|
|
36
|
+
eligible[field.name] = field.dependencies.all? do |name|
|
|
37
|
+
dependency = granted[name]
|
|
38
|
+
dependency && visit.call(dependency, path + [ field.name ])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
fields.select { |field| visit.call(field, []) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Fields
|
|
5
|
+
# Raster images a browser may show inline. SVG and HTML never are: they
|
|
6
|
+
# can carry script.
|
|
7
|
+
INLINE_IMAGE_TYPES = %w[image/png image/jpeg image/gif image/webp image/avif].freeze
|
|
8
|
+
|
|
9
|
+
# A chosen file the page refused before uploading it (too large).
|
|
10
|
+
RejectedUpload = Data.define(:reason, :limit)
|
|
11
|
+
|
|
12
|
+
# The readable facts about one attached file. Pages link to it through the
|
|
13
|
+
# admin's own download route, never through a public blob URL.
|
|
14
|
+
# id is the attachment id of one of several files (has_many_attached).
|
|
15
|
+
FileValue = Data.define(:filename, :content_type, :byte_size, :checksum, :id) do
|
|
16
|
+
def self.from(blob, id: nil)
|
|
17
|
+
new(blob.filename.to_s.freeze, blob.content_type.to_s.freeze, blob.byte_size, blob.checksum.to_s.freeze, id&.to_s&.freeze)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def image? = INLINE_IMAGE_TYPES.include?(content_type)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Fields
|
|
5
|
+
module HookSignatures
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def accepts?(method, count, keywords)
|
|
9
|
+
params = method.parameters
|
|
10
|
+
minimum = params.count { |kind, _| kind == :req }
|
|
11
|
+
maximum = params.any? { |kind, _| kind == :rest } ? Float::INFINITY : minimum + params.count { |kind, _| kind == :opt }
|
|
12
|
+
(minimum..maximum).cover?(count) && (params.filter_map { |kind, key| key if kind == :keyreq } - keywords).empty? &&
|
|
13
|
+
!params.any? { |kind, _| kind == :nokey } &&
|
|
14
|
+
(params.any? { |kind, _| kind == :keyrest } || (keywords - params.filter_map { |kind, key| key if %i[key keyreq].include?(kind) }).empty?)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Fields
|
|
5
|
+
# One current projection, with no cross-request/identity value cache.
|
|
6
|
+
class Projection
|
|
7
|
+
MAX_VALUE_BYTES = 16_384
|
|
8
|
+
MAX_OUTPUT_BYTES = 65_536
|
|
9
|
+
MAX_SECONDS = 1.0
|
|
10
|
+
|
|
11
|
+
def initialize(access, record, fields)
|
|
12
|
+
@access, @record = access, record
|
|
13
|
+
@fields = fields.index_by(&:name)
|
|
14
|
+
@computed = fields.any?(&:virtual?)
|
|
15
|
+
@cache = {}
|
|
16
|
+
@bytes = 0
|
|
17
|
+
@deadline = monotonic + MAX_SECONDS
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def values(fields: @fields.values)
|
|
21
|
+
result = fields.to_h { |field| [ field.name.to_s, value(field) ] }
|
|
22
|
+
check_deadline!
|
|
23
|
+
result
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def value(field)
|
|
27
|
+
check_deadline!
|
|
28
|
+
raise AccessDenied unless @fields[field.name].equal?(field)
|
|
29
|
+
return @cache[field.name] if @cache.key?(field.name)
|
|
30
|
+
unless field.virtual?
|
|
31
|
+
scalar = @access.read_attribute(@record, field)
|
|
32
|
+
check_deadline!
|
|
33
|
+
return @cache[field.name] = scalar
|
|
34
|
+
end
|
|
35
|
+
computed = ActiveRecord::Base.while_preventing_writes do
|
|
36
|
+
dependencies = field.dependencies.to_h { |name| [ name.to_s, snapshot(value(@fields.fetch(name))) ] }.freeze
|
|
37
|
+
check_bound!(dependencies)
|
|
38
|
+
field.compute(dependencies, time_zone: @access.context.time_zone)
|
|
39
|
+
end
|
|
40
|
+
check_deadline!
|
|
41
|
+
@bytes += check_bound!(computed)
|
|
42
|
+
raise ConfigurationError, "Computed projection exceeds output bound" if @bytes > MAX_OUTPUT_BYTES
|
|
43
|
+
@cache[field.name] = computed
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def snapshot(value)
|
|
49
|
+
copy = case value
|
|
50
|
+
when nil, true, false, Integer then value
|
|
51
|
+
when String then String.new(value)
|
|
52
|
+
when BigDecimal
|
|
53
|
+
raise ConfigurationError, "Computed dependencies require finite decimals" unless value.finite?
|
|
54
|
+
value
|
|
55
|
+
when Time, ActiveSupport::TimeWithZone, DateTime then value.to_time.getutc
|
|
56
|
+
when Date then Date.iso8601(value.iso8601)
|
|
57
|
+
else raise ConfigurationError, "Computed dependencies require supported scalars"
|
|
58
|
+
end
|
|
59
|
+
check_bound!(copy)
|
|
60
|
+
copy.freeze
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_bound!(value)
|
|
64
|
+
bytes = JSON.generate(InputSchema.canonical(value)).bytesize
|
|
65
|
+
raise ConfigurationError, "Computed scalar exceeds size bound" if bytes > MAX_VALUE_BYTES
|
|
66
|
+
bytes
|
|
67
|
+
rescue JSON::GeneratorError, EncodingError
|
|
68
|
+
raise ConfigurationError, "Computed scalar encoding is invalid", cause: nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
72
|
+
|
|
73
|
+
def check_deadline!
|
|
74
|
+
raise ConfigurationError, "Computed projection exceeded its cooperative budget" if @computed && monotonic > @deadline
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Fields
|
|
5
|
+
class Registry
|
|
6
|
+
Definition = Data.define(:key, :handler_name) do
|
|
7
|
+
def handler_class
|
|
8
|
+
klass = handler_name.constantize
|
|
9
|
+
unless klass.is_a?(Class) && klass < Type && klass.name
|
|
10
|
+
raise ConfigurationError, "Registered field type must inherit RailsAiAdmin::Fields::Type"
|
|
11
|
+
end
|
|
12
|
+
klass
|
|
13
|
+
rescue NameError
|
|
14
|
+
raise ConfigurationError, "Registered field type could not be loaded", cause: nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def metadata = handler_class.metadata
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(types)
|
|
21
|
+
unless types.is_a?(Hash) && types.all? { |key, name| (key.is_a?(String) || key.is_a?(Symbol)) && key.to_s.match?(/\A[a-z][a-z0-9_]{0,39}\z/) && name.is_a?(String) && name.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/) }
|
|
22
|
+
raise ConfigurationError, "field_types must map stable keys to class-name strings"
|
|
23
|
+
end
|
|
24
|
+
@mapping = types.transform_keys(&:to_s).transform_values(&:dup)
|
|
25
|
+
if @mapping.size != types.size || (@mapping.keys & Field::TYPES.map(&:to_s)).any?
|
|
26
|
+
raise ConfigurationError, "Field registrations cannot duplicate or replace built-in types"
|
|
27
|
+
end
|
|
28
|
+
@mapping.each { |key, name| key.freeze; name.freeze }
|
|
29
|
+
@mapping.freeze
|
|
30
|
+
@entries = @mapping.to_h { |key, name| [ key, Definition.new(key, name) ] }.freeze
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def matches?(types) = types.is_a?(Hash) && types.transform_keys(&:to_s) == @mapping
|
|
35
|
+
def definitions = @entries.values
|
|
36
|
+
def validate!
|
|
37
|
+
definitions.each(&:metadata)
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def build(name, type:, source: nil, **options)
|
|
42
|
+
return Field.new(name, type: type, source: source, **options) if Field::TYPES.include?(type.to_sym)
|
|
43
|
+
definition = @entries.fetch(type.to_s) { raise ConfigurationError, "Unregistered field type" }
|
|
44
|
+
Field.new(name, type: type, plugin: definition, source: source, **options)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json_schemer"
|
|
4
|
+
|
|
5
|
+
module RailsAiAdmin
|
|
6
|
+
module Fields
|
|
7
|
+
# A deliberately scalar-only subset of the released Surface schema contract.
|
|
8
|
+
# Core constraints stay outside the extension in an intersection.
|
|
9
|
+
module ScalarSchema
|
|
10
|
+
SHARED_KEYS = %w[type enum const title description].freeze
|
|
11
|
+
TYPE_KEYS = { "string" => %w[pattern minLength maxLength], "integer" => %w[minimum maximum], "boolean" => [] }.freeze
|
|
12
|
+
def self.scalar?(value)
|
|
13
|
+
value.nil? || value == true || value == false || value.is_a?(String) && value.valid_encoding? ||
|
|
14
|
+
value.is_a?(Integer) || value.is_a?(Float) && value.finite?
|
|
15
|
+
end
|
|
16
|
+
private_class_method :scalar?
|
|
17
|
+
|
|
18
|
+
def self.compile(base, constraints)
|
|
19
|
+
unless constraints.is_a?(Hash) && constraints.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) } && constraints.stringify_keys.size == constraints.size
|
|
20
|
+
raise ConfigurationError, "Field schema constraints must be a scalar keyword hash"
|
|
21
|
+
end
|
|
22
|
+
constraints = constraints.stringify_keys
|
|
23
|
+
allowed_keys = SHARED_KEYS + TYPE_KEYS.fetch(base.fetch("type"))
|
|
24
|
+
if (constraints.keys - allowed_keys).any? || constraints.key?("type") && constraints["type"] != base.fetch("type")
|
|
25
|
+
raise ConfigurationError, "Field schema cannot change the core scalar type or use unsupported keywords"
|
|
26
|
+
end
|
|
27
|
+
if constraints["pattern"] && (!constraints["pattern"].is_a?(String) || constraints["pattern"].bytesize > 1000)
|
|
28
|
+
raise ConfigurationError, "Field schema pattern must be bounded text"
|
|
29
|
+
end
|
|
30
|
+
%w[title description].each do |key|
|
|
31
|
+
raise ConfigurationError, "Field schema text must be bounded" if constraints[key] && (!constraints[key].is_a?(String) || constraints[key].bytesize > 300)
|
|
32
|
+
end
|
|
33
|
+
if constraints.key?("const") && !scalar?(constraints["const"]) || constraints.key?("enum") &&
|
|
34
|
+
(!constraints["enum"].is_a?(Array) || !constraints["enum"].all? { |value| scalar?(value) })
|
|
35
|
+
raise ConfigurationError, "Field enum and const constraints must contain JSON scalars"
|
|
36
|
+
end
|
|
37
|
+
encoded = JSON.generate(constraints)
|
|
38
|
+
raise ConfigurationError, "Field schema is too large" if encoded.bytesize > 8192
|
|
39
|
+
constraints = JSON.parse(encoded)
|
|
40
|
+
schema = constraints.empty? ? base : base.merge("allOf" => [ constraints ])
|
|
41
|
+
validator = JSONSchemer.schema(schema, ref_resolver: ->(*) { raise ConfigurationError, "Field schema references are unsupported" })
|
|
42
|
+
raise ConfigurationError, "Invalid scalar field schema" unless validator.valid_schema?
|
|
43
|
+
[ schema, validator ]
|
|
44
|
+
rescue JSON::GeneratorError, JSON::ParserError, EncodingError, RegexpError, ArgumentError
|
|
45
|
+
raise ConfigurationError, "Invalid scalar field schema", cause: nil
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Fields
|
|
5
|
+
# Pure refinements of an explicitly granted core scalar. Never an authority
|
|
6
|
+
# or record lookup API. Each invocation gets a new, stateless instance.
|
|
7
|
+
class Type
|
|
8
|
+
extend HookSignatures
|
|
9
|
+
class << self
|
|
10
|
+
def scalar(value = nil)
|
|
11
|
+
@scalar = value.to_sym if value
|
|
12
|
+
@scalar
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def version(value = nil)
|
|
16
|
+
@version = value if value
|
|
17
|
+
@version || "1"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
%i[form_partial index_partial show_partial].each do |method|
|
|
21
|
+
define_method(method) do |value = nil|
|
|
22
|
+
instance_variable_set("@#{method}", value) if value
|
|
23
|
+
instance_variable_get("@#{method}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def metadata
|
|
28
|
+
unless scalar && Field::TYPES.include?(scalar) && scalar != :belongs_to && version.is_a?(String) && version.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/)
|
|
29
|
+
raise ConfigurationError, "Field type needs a built-in scalar and bounded version"
|
|
30
|
+
end
|
|
31
|
+
partials = %i[form_partial index_partial show_partial].to_h { |key| [ key, public_send(key) ] }
|
|
32
|
+
unless partials.values.all? { |path| path.nil? || path.is_a?(String) && path.length <= 200 && path.match?(/\A[a-z][a-z0-9_]*(?:\/[a-z][a-z0-9_]*)*\z/) }
|
|
33
|
+
raise ConfigurationError, "Field partials must be trusted relative Rails view paths"
|
|
34
|
+
end
|
|
35
|
+
methods = { normalize: [ 1, %i[field time_zone] ], valid?: [ 1, %i[field time_zone] ], format: [ 1, %i[field time_zone] ], schema: [ 0, [ :field ] ] }
|
|
36
|
+
unless respond_to?(:new) && accepts?(method(:new), 0, []) && accepts?(instance_method(:initialize), 0, []) &&
|
|
37
|
+
methods.all? { |key, (count, keywords)| public_instance_methods.include?(key) && accepts?(instance_method(key), count, keywords) }
|
|
38
|
+
raise ConfigurationError, "Field type has an incompatible public constructor or hook signature"
|
|
39
|
+
end
|
|
40
|
+
{ handler: name, scalar: scalar, version: version, **partials }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def normalize(value, field:, time_zone:) = value
|
|
45
|
+
def valid?(value, field:, time_zone:) = true
|
|
46
|
+
def format(text, field:, time_zone:) = text
|
|
47
|
+
def schema(field:) = {}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiAdmin
|
|
4
|
+
module Forms
|
|
5
|
+
# Presentation-only snapshots; callers supply currently authorized fields.
|
|
6
|
+
# No record, denied value, query or persistence lives in this plan.
|
|
7
|
+
class Plan
|
|
8
|
+
Entry = Data.define(:field, :editable, :value)
|
|
9
|
+
Panel = Data.define(:label, :entries)
|
|
10
|
+
Tab = Data.define(:label, :panels)
|
|
11
|
+
MAX_FIELDS = 128
|
|
12
|
+
MAX_TABS = 8
|
|
13
|
+
MAX_PANELS = 16
|
|
14
|
+
MAX_VALUE_BYTES = 262_144
|
|
15
|
+
MAX_BYTES = 1_048_576
|
|
16
|
+
attr_reader :tabs, :entries
|
|
17
|
+
|
|
18
|
+
def initialize(fields:, values:, readonly_fields: [], readonly_values: {}, order: nil)
|
|
19
|
+
unless values.is_a?(Hash) && readonly_values.is_a?(Hash)
|
|
20
|
+
raise ConfigurationError, "Form values must be scalar maps"
|
|
21
|
+
end
|
|
22
|
+
writable = fields.reject(&:hidden?).index_by(&:name)
|
|
23
|
+
readable = readonly_fields.reject(&:hidden?).index_by(&:name).except(*writable.keys)
|
|
24
|
+
@bytes = 0
|
|
25
|
+
@entries = (Array(order) + fields + readonly_fields).uniq(&:name).filter_map do |field|
|
|
26
|
+
next unless writable.key?(field.name) || readable.key?(field.name)
|
|
27
|
+
editable = writable.key?(field.name)
|
|
28
|
+
source = editable ? values : readonly_values
|
|
29
|
+
value = source.key?(field.name.to_s) ? source[field.name.to_s] : source[field.name]
|
|
30
|
+
Entry.new(field, editable, scalar(value, type: field.scalar_type))
|
|
31
|
+
end.freeze
|
|
32
|
+
raise ConfigurationError, "Form field bound exceeded" if @entries.size > MAX_FIELDS
|
|
33
|
+
@tabbed = @entries.any? { |entry| entry.field.options[:tab] }
|
|
34
|
+
groups = @entries.group_by { |entry| entry.field.options[:tab] || (@tabbed ? "Details" : nil) }
|
|
35
|
+
raise ConfigurationError, "Form tab bound exceeded" if groups.size > MAX_TABS
|
|
36
|
+
@tabs = groups.map do |label, entries|
|
|
37
|
+
panels = entries.group_by { |entry| entry.field.options[:panel] }
|
|
38
|
+
raise ConfigurationError, "Form panel bound exceeded" if panels.size > MAX_PANELS
|
|
39
|
+
Tab.new(label, panels.map { |title, items| Panel.new(title, items.freeze) }.freeze)
|
|
40
|
+
end.freeze
|
|
41
|
+
@tabbed &&= @tabs.size > 1
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def tabbed? = @tabbed
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def scalar(value, type: nil)
|
|
50
|
+
copy = case value
|
|
51
|
+
when nil, true, false, Integer then value
|
|
52
|
+
when String then String.new(value).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
53
|
+
when BigDecimal
|
|
54
|
+
raise ConfigurationError, "Form decimals must be finite" unless value.finite?
|
|
55
|
+
value
|
|
56
|
+
when Time, ActiveSupport::TimeWithZone, DateTime then value.to_time.getutc
|
|
57
|
+
when Date then Date.iso8601(value.iso8601)
|
|
58
|
+
when Fields::FileValue then value
|
|
59
|
+
# A references field carries its target keys.
|
|
60
|
+
when Array
|
|
61
|
+
if type == :files
|
|
62
|
+
raise ConfigurationError, "Form files must be file values" unless value.all? { |item| item.is_a?(Fields::FileValue) }
|
|
63
|
+
value.dup.freeze
|
|
64
|
+
elsif type == :references
|
|
65
|
+
raise ConfigurationError, "Form references must be key text" unless value.all? { |item| item.is_a?(String) }
|
|
66
|
+
value.map { |item| String.new(item).freeze }.freeze
|
|
67
|
+
else
|
|
68
|
+
raise ConfigurationError, "Form values require supported scalars" unless type == :json
|
|
69
|
+
json_text(value)
|
|
70
|
+
end
|
|
71
|
+
# A JSON field edits its document as indented text.
|
|
72
|
+
when Hash, Float
|
|
73
|
+
raise ConfigurationError, "Form values require supported scalars" unless type == :json
|
|
74
|
+
json_text(value)
|
|
75
|
+
else raise ConfigurationError, "Form values require supported scalars"
|
|
76
|
+
end
|
|
77
|
+
bytes = JSON.generate(InputSchema.canonical(copy)).bytesize
|
|
78
|
+
@bytes += bytes
|
|
79
|
+
raise ConfigurationError, "Form value bound exceeded" if bytes > MAX_VALUE_BYTES || @bytes > MAX_BYTES
|
|
80
|
+
copy.freeze
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def json_text(value)
|
|
84
|
+
JSON.pretty_generate(value)
|
|
85
|
+
rescue JSON::GeneratorError
|
|
86
|
+
raise ConfigurationError, "Form JSON values must be finite", cause: nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|