export_kit 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 401631ec4132efca4eb76e051db2e90bef4e3371610198ae45e311a4d11f446b
4
+ data.tar.gz: 9e1b1d4c5bf0fe15c7d0848c00903453548f52c58362eed1749235416ce852f7
5
+ SHA512:
6
+ metadata.gz: 69afffa72dec01eea9973e2f2aefff6c7956562bcf4ce56f7b442717f79d02e624b593d74cee98cf825fded7060f7ff90c61fc5d762b5b3e3f97a17e667d3c26
7
+ data.tar.gz: 8e626eaecd333db03433bd5ec756922f2beed0796711fd6015bcdaadf233ab328f8f67a9dc58ab95871104ae4b3f0ccb0ba370ac1caf33581c0f37e71d2bf3e7
data/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-10-26
4
+
5
+ First public release.
6
+
7
+ - Renders hashes and explicit tables as JSON or CSV.
8
+ - Renders multi-section documents as JSON, ZIP archives of CSV tables, or PDF.
9
+ - Enforces a caller-provided output-size limit while data is written.
10
+ - Neutralizes spreadsheet formulas in CSV cells.
11
+ - Returns typed results for expected failures and removes partial temporary files.
12
+ - Verifies artifact digests and validates the structure of supported formats.
13
+ - Provides an optional Active Record adapter with explicit allow/deny schemas and keyset batching.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Łukasz Kerl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # ExportKit
2
+
3
+ Portable, size-bounded exports for Ruby applications.
4
+
5
+ ExportKit turns plain Ruby data, explicit tables or multi-section documents into JSON, CSV,
6
+ ZIP archives of CSV tables, and PDF. It streams rows, stops writing at a caller-provided byte
7
+ limit, neutralizes spreadsheet formulas, removes partial temporary files and can validate the
8
+ artifact it produced.
9
+
10
+ *(Dokumentacja po polsku: [README.pl.md](README.pl.md))*
11
+
12
+ ## Why this exists
13
+
14
+ An export is more than `CSV.generate`: the caller must decide what may leave the application,
15
+ preserve a stable schema, avoid loading an unbounded dataset, prevent formula injection, clean up
16
+ temporary files and distinguish an expected refusal from a programming error. ExportKit owns those
17
+ format-neutral mechanics. The application still owns authorization and the business meaning of the
18
+ data.
19
+
20
+ The core does not load Rails or Active Record. The optional adapter is enabled explicitly.
21
+
22
+ ## Installation
23
+
24
+ ```ruby
25
+ gem "export_kit", "~> 0.1.0"
26
+ ```
27
+
28
+ ExportKit requires Ruby 3.4 or newer.
29
+
30
+ ## A single table
31
+
32
+ ```ruby
33
+ require "export_kit"
34
+ require "fileutils"
35
+
36
+ table = ExportKit::Table.new(
37
+ columns: %i[sku name price],
38
+ labels: { sku: "SKU", name: "Name", price: "Price" },
39
+ rows: [
40
+ { sku: "LAMP-01", name: "Ceramic lamp", price: "149.00" },
41
+ { sku: "CHAIR-02", name: "=HYPERLINK(...)", price: "320.00" }
42
+ ]
43
+ )
44
+
45
+ result = ExportKit.render(table, format: :csv, max_bytes: 5 * 1024**2)
46
+
47
+ if result.success?
48
+ artifact = result.value!
49
+ begin
50
+ FileUtils.copy_file(artifact.file.path, "products.csv")
51
+ ensure
52
+ artifact.close!
53
+ end
54
+ else
55
+ warn result.error
56
+ end
57
+ ```
58
+
59
+ Column order is explicit. Every row must contain exactly the declared keys. CSV headers use the
60
+ labels, while JSON keeps the stable column keys. Strings beginning with `=`, `+`, `-` or `@` are
61
+ prefixed with an apostrophe in CSV output to prevent spreadsheet formula execution.
62
+
63
+ `rows` may also be a callback, which avoids materializing the whole dataset:
64
+
65
+ ```ruby
66
+ rows = lambda do |&emit|
67
+ source.each_batch { |batch| batch.each { |record| emit.call(record) } }
68
+ end
69
+
70
+ table = ExportKit::Table.new(columns: %i[id name], rows: rows)
71
+ ```
72
+
73
+ The source is consumed synchronously and once per render.
74
+
75
+ ## Plain JSON
76
+
77
+ Plain hashes and arrays can be rendered directly as JSON:
78
+
79
+ ```ruby
80
+ result = ExportKit.render(
81
+ { product: { sku: "LAMP-01", tags: ["vintage"] } },
82
+ format: :json,
83
+ max_bytes: 1_000_000
84
+ )
85
+ ```
86
+
87
+ Supported values are hashes with string or symbol keys, arrays, strings, finite numbers, booleans
88
+ and `nil`. Nesting deeper than 100 levels is rejected.
89
+
90
+ ## Multi-section documents
91
+
92
+ `ExportKit::Document` describes a versioned export independently of its output format. Each record
93
+ set is an explicit table backed by a row callback.
94
+
95
+ ```ruby
96
+ users = ExportKit::Document::RecordSet.new(
97
+ name: "users",
98
+ columns: %i[id email],
99
+ column_labels: { id: "ID", email: "Email" },
100
+ row_source: ->(&emit) { authorized_users.find_each { |user| emit.call(id: user.id, email: user.email) } }
101
+ )
102
+
103
+ people = ExportKit::Document::Section.new(
104
+ key: "people",
105
+ label: "People",
106
+ description: "Authorized user records",
107
+ metadata: {},
108
+ record_sets: [users]
109
+ )
110
+
111
+ document = ExportKit::Document.new(
112
+ identifier: "account_export",
113
+ schema_version: 1,
114
+ title: "Account export",
115
+ metadata: { requested_by: { user_id: 42 } },
116
+ sections: [people]
117
+ )
118
+
119
+ result = ExportKit.render(document, format: :csv_zip, max_bytes: 20 * 1024**2)
120
+ ```
121
+
122
+ Supported document formats:
123
+
124
+ | format | artifact | notes |
125
+ |---|---|---|
126
+ | `json` | `.json` | versioned envelope, manifest and section data |
127
+ | `csv_zip` | `.zip` | one UTF-8 CSV per non-empty record set plus `manifest.csv` |
128
+ | `pdf` | `.pdf` | human-readable A4 report; requires an explicit Unicode font |
129
+
130
+ For PDF, provide a font file owned by the consuming application:
131
+
132
+ ```ruby
133
+ ExportKit.render(
134
+ document,
135
+ format: :pdf,
136
+ max_bytes: 20 * 1024**2,
137
+ font: Rails.root.join("app/assets/fonts/Inter-Regular.ttf").to_s,
138
+ bold_font: Rails.root.join("app/assets/fonts/Inter-Bold.ttf").to_s
139
+ )
140
+ ```
141
+
142
+ ## Active Record adapter
143
+
144
+ Load the adapter explicitly:
145
+
146
+ ```ruby
147
+ require "export_kit/active_record"
148
+ ```
149
+
150
+ For a direct table, pass an authorized relation and a field allowlist:
151
+
152
+ ```ruby
153
+ table = ExportKit.records(
154
+ policy_scope(Order).reorder(nil),
155
+ fields: %i[id number total_minor],
156
+ labels: { number: "Order", total_minor: "Total (minor units)" },
157
+ batch_size: 500
158
+ )
159
+ ```
160
+
161
+ Models may define a complete export policy and use `fields: :all` in serializer definitions:
162
+
163
+ ```ruby
164
+ class User < ApplicationRecord
165
+ include ExportKit::ModelSchema
166
+
167
+ export_schema do
168
+ allow :id, :email
169
+ timestamps
170
+ omit :password_digest, :reset_token, reason: "authentication secrets"
171
+ end
172
+ end
173
+
174
+ User.validate_export_schema!
175
+ ```
176
+
177
+ The validation requires every database column to be either allowed or denied with a reason. This
178
+ is a schema ratchet, not authorization. The application must still provide the correct scoped
179
+ relation.
180
+
181
+ The batching adapter accepts an unordered, ungrouped relation without `offset` or `distinct`, and
182
+ requires a single primary key. It paginates by that primary key and respects an existing `limit`.
183
+
184
+ ## Results and cleanup
185
+
186
+ Expected render failures are returned as `ExportKit::Result`:
187
+
188
+ | error | meaning |
189
+ |---|---|
190
+ | `export_format_unsupported` | no renderer exists for the requested format |
191
+ | `export_too_large` | the byte limit was exceeded |
192
+ | `export_render_failed` | input or renderer output was invalid |
193
+
194
+ `Result` provides `success?`, `failure?`, `value`, `value!`, `error`, `error_details`, `map`,
195
+ `map_error` and `and_then`. Safe error details may identify a section, record set, row and column;
196
+ they never contain the rejected value.
197
+
198
+ Programming errors from a custom renderer or row source propagate after temporary output is
199
+ removed. The caller owns a successful artifact and must call `close!` (or its alias `cleanup`) in
200
+ an `ensure` block.
201
+
202
+ ## Validation
203
+
204
+ ```ruby
205
+ artifact.verify # digest and byte count
206
+ ExportKit.validate(artifact) # digest plus supported-format structure
207
+ ```
208
+
209
+ Validation detects accidental corruption and malformed manifests. It does not prove that the
210
+ application selected every required record or excluded every private field.
211
+
212
+ ## Security boundaries
213
+
214
+ ExportKit provides bounded writes, CSV formula neutralization, explicit schemas, safe error
215
+ locations, temporary-file cleanup and structural validation. It deliberately does not provide:
216
+
217
+ - authentication or authorization;
218
+ - tenant or policy scoping;
219
+ - transactional snapshots across several queries;
220
+ - encryption, object storage, downloads or retention;
221
+ - privacy-law completeness checks;
222
+ - a safe sandbox for custom renderers.
223
+
224
+ Treat custom renderers as trusted application code. Do not expose artifact paths in logs or error
225
+ responses. Set `max_bytes` from an application-level policy and deliver artifacts through an
226
+ authorized channel.
227
+
228
+ ## Development
229
+
230
+ ```bash
231
+ bin/setup
232
+ bundle exec rake test
233
+ bundle exec rubocop
234
+ gem build export_kit.gemspec
235
+ ```
236
+
237
+ The test suite does not use the network. See [CONTRIBUTING.md](CONTRIBUTING.md),
238
+ [SECURITY.md](SECURITY.md) and [docs/releasing.md](docs/releasing.md).
239
+
240
+ ## License
241
+
242
+ The gem is available under the [MIT License](LICENSE.txt).
data/README.pl.md ADDED
@@ -0,0 +1,113 @@
1
+ # ExportKit
2
+
3
+ Przenośne eksporty Ruby z jawnym limitem rozmiaru.
4
+
5
+ ExportKit zamienia zwykłe dane Ruby, jawne tabele albo dokumenty z wieloma sekcjami na JSON, CSV,
6
+ archiwa ZIP z tabelami CSV oraz PDF. Strumieniuje wiersze, zatrzymuje zapis po przekroczeniu limitu,
7
+ neutralizuje formuły arkusza, sprząta częściowe pliki tymczasowe i potrafi sprawdzić wynikowy artefakt.
8
+
9
+ Pełna dokumentacja API i przykłady znajdują się w angielskim [README.md](README.md). Ten plik
10
+ podsumowuje kontrakt integracyjny po polsku.
11
+
12
+ ## Instalacja
13
+
14
+ ```ruby
15
+ gem "export_kit", "~> 0.1.0"
16
+ ```
17
+
18
+ Wymagany jest Ruby 3.4 lub nowszy. Rdzeń nie uruchamia Rails ani Active Record. Adapter modeli
19
+ włącza się jawnie przez `require "export_kit/active_record"`.
20
+
21
+ ## Pojedyncza tabela
22
+
23
+ ```ruby
24
+ require "export_kit"
25
+
26
+ table = ExportKit::Table.new(
27
+ columns: %i[number total],
28
+ labels: { number: "Numer", total: "Kwota" },
29
+ rows: [{ number: "GV-001", total: "149.00" }]
30
+ )
31
+
32
+ result = ExportKit.render(table, format: :csv, max_bytes: 5 * 1024**2)
33
+
34
+ if result.success?
35
+ artifact = result.value!
36
+ begin
37
+ send_data artifact.file.read, type: artifact.content_type
38
+ ensure
39
+ artifact.close!
40
+ end
41
+ end
42
+ ```
43
+
44
+ Każdy wiersz musi mieć dokładnie zadeklarowane kolumny. Kolejność kolumn jest stabilna. Wartości
45
+ CSV zaczynające się od `=`, `+`, `-` albo `@` dostają apostrof, aby arkusz nie wykonał ich jako
46
+ formuły. `rows` może być callbackiem przyjmującym blok `emit`, dzięki czemu nie trzeba budować całej
47
+ tablicy w pamięci.
48
+
49
+ ## Formaty
50
+
51
+ - zwykły Hash lub Array: `json`;
52
+ - `ExportKit::Table`: `json` albo `csv`;
53
+ - `ExportKit::Document`: `json`, `csv_zip` albo `pdf`.
54
+
55
+ Dokument składa się z wersjonowanego identyfikatora, metadanych, sekcji i nazwanych zestawów
56
+ rekordów. ZIP zawiera osobny CSV dla każdego niepustego zestawu oraz `manifest.csv`. PDF wymaga
57
+ jawnej ścieżki do fontu Unicode dostarczonego przez aplikację.
58
+
59
+ ## Wynik i sprzątanie
60
+
61
+ Oczekiwane odmowy nie rzucają wyjątku:
62
+
63
+ - `export_format_unsupported` — nieobsługiwany format;
64
+ - `export_too_large` — przekroczony limit bajtów;
65
+ - `export_render_failed` — niepoprawne dane albo wynik renderera.
66
+
67
+ Szczegóły błędu mogą wskazać sekcję, zestaw, wiersz i kolumnę, ale nie zawierają wartości danych.
68
+ Błędy programistyczne propagują wyjątek po usunięciu pliku częściowego. Udany artefakt należy
69
+ zawsze zamknąć przez `close!`/`cleanup` w `ensure`.
70
+
71
+ ## Active Record
72
+
73
+ ```ruby
74
+ require "export_kit/active_record"
75
+
76
+ table = ExportKit.records(
77
+ policy_scope(Order).reorder(nil),
78
+ fields: %i[id number total_minor],
79
+ batch_size: 500
80
+ )
81
+ ```
82
+
83
+ Adapter wymaga relacji już ograniczonej przez aplikację. Może także pilnować kompletnej klasyfikacji
84
+ kolumn modelu: każda kolumna trafia wtedy na jawną allowlistę albo denylistę z powodem. To bezpiecznik
85
+ schematu, nie zamiennik Pundita ani innej autoryzacji.
86
+
87
+ Relacja do stronicowania musi być bez `order`, `offset`, `group` i `distinct` oraz mieć pojedynczy
88
+ klucz główny. Adapter stronicuje po kluczu i respektuje istniejący `limit`.
89
+
90
+ ## Granica odpowiedzialności
91
+
92
+ Biblioteka odpowiada za mechanikę formatów, limity zapisu, neutralizację formuł, sprzątanie i
93
+ walidację struktury. Aplikacja odpowiada za:
94
+
95
+ - uwierzytelnienie, autoryzację i zakres danych;
96
+ - spójność odczytu;
97
+ - minimalizację danych i zgodność z wymaganiami prywatności;
98
+ - szyfrowanie, storage, pobieranie i retencję;
99
+ - zaufanie do własnych rendererów.
100
+
101
+ ## Rozwój i wydania
102
+
103
+ ```bash
104
+ bin/setup
105
+ bundle exec rake test
106
+ bundle exec rubocop
107
+ gem build export_kit.gemspec
108
+ ```
109
+
110
+ Wydania idą z GitHub Actions do RubyGems.org przez OIDC Trusted Publishing, bez długowiecznego
111
+ tokena w sekretach repo. Szczegóły: [docs/releasing.md](docs/releasing.md).
112
+
113
+ Kod jest dostępny na licencji [MIT](LICENSE.txt).
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "export_kit"
4
+ require "active_record"
5
+ require "export_kit/model_schema"
6
+ require "export_kit/model_serializer"
7
+ require "export_kit/model_rows"
8
+
9
+ module ExportKit
10
+ def self.records(scope, fields:, labels: {}, batch_size: 500)
11
+ set = ModelSerializer::RecordSetBuilder.new(
12
+ "rows", model: scope.klass, scope: ->(_) { scope }, fields:, schema: nil
13
+ ).build
14
+ Table.new(columns: set.output_keys, labels:, rows: ->(&emit) { set.each_row(batch_size:, &emit) })
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExportKit
4
+ Artifact = Data.define(:file, :byte_size, :sha256, :manifest, :format, :content_type, :extension,
5
+ :identifier, :schema_version, :metadata, :document) do
6
+ def close!
7
+ file.close!
8
+ nil
9
+ end
10
+ alias_method :cleanup, :close!
11
+
12
+ def verify
13
+ valid ? Result.success(self) : Result.failure(ValidateArtifact::INVALID)
14
+ rescue Errno::ENOENT, IOError
15
+ Result.failure(ValidateArtifact::INVALID)
16
+ end
17
+
18
+ private
19
+
20
+ def valid = file.path && File.size(file.path) == byte_size && Digest::SHA256.file(file.path).hexdigest == sha256
21
+ end
22
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExportKit
4
+ # Format-neutral input for export renderers. A source adapter describes the document and
5
+ # streams rows; renderers know nothing about Active Record or the domain that produced them.
6
+ class Document
7
+ KEY_FORMAT = /\A[a-z][a-z0-9_]{0,63}\z/
8
+ IDENTIFIER_FORMAT = /\A[a-z][a-z0-9_]{2,63}\z/
9
+
10
+ attr_reader :identifier, :metadata, :schema_version, :sections, :title
11
+
12
+ def self.string(value) = value.to_s.dup.freeze
13
+
14
+ def self.normalize_metadata(value, depth = 0)
15
+ raise ArgumentError, "metadata nesting exceeds limit" if depth > 100
16
+
17
+ case value
18
+ when Hash then normalize_hash(value, depth)
19
+ when Array then value.map { normalize_metadata(it, depth + 1) }.freeze
20
+ when String then value.dup.freeze
21
+ when Integer, Float, TrueClass, FalseClass, NilClass then value
22
+ else raise ArgumentError, "unsupported metadata value"
23
+ end
24
+ end
25
+
26
+ def self.normalize_hash(value, depth)
27
+ keys = value.keys.map(&:to_s)
28
+ raise ArgumentError, "duplicate metadata keys" unless keys.uniq == keys
29
+
30
+ value.to_h { |key, item| [string(key), normalize_metadata(item, depth + 1)] }.freeze
31
+ end
32
+
33
+ def self.validate_metadata!(metadata, reserved)
34
+ raise ArgumentError, "metadata must be a hash" unless metadata.is_a?(Hash)
35
+ raise ArgumentError, "reserved metadata key" if metadata.keys.intersect?(reserved)
36
+ end
37
+
38
+ # One logical group in a document. Metadata is copied into every format manifest.
39
+ class Section
40
+ attr_reader :description, :key, :label, :metadata, :record_sets
41
+
42
+ def initialize(key:, label:, description:, metadata:, record_sets:)
43
+ @key = Document.string(key)
44
+ @label = Document.string(label)
45
+ @description = Document.string(description)
46
+ @metadata = Document.normalize_metadata(metadata)
47
+ @record_sets = Array(record_sets).dup.freeze
48
+ validate!
49
+ end
50
+
51
+ private
52
+
53
+ def validate!
54
+ Document.validate_metadata!(metadata, %w(category label description record_sets count))
55
+ raise ArgumentError, "invalid export section key" unless key.match?(KEY_FORMAT)
56
+ raise ArgumentError, "export section label is required" if label.strip.empty?
57
+
58
+ validate_record_sets!
59
+ end
60
+
61
+ def validate_record_sets!
62
+ raise ArgumentError, "invalid record sets" unless record_sets.all?(RecordSet)
63
+ return if record_sets.map(&:name).uniq.size == record_sets.size
64
+
65
+ raise ArgumentError, "export record-set names must be unique"
66
+ end
67
+ end
68
+
69
+ # A named table with an explicit column order and a callback that yields one Hash per row.
70
+ class RecordSet
71
+ attr_reader :column_labels, :columns, :name
72
+
73
+ def initialize(name:, columns:, row_source:, column_labels: {})
74
+ @name = Document.string(name)
75
+ @columns = Array(columns).map { Document.string(it) }.freeze
76
+ @column_labels = @columns.to_h { [it, it] }.merge(
77
+ column_labels.to_h { |key, value| [Document.string(key), Document.string(value)] }
78
+ ).freeze
79
+ @row_source = row_source
80
+ validate!
81
+ end
82
+
83
+ def headers
84
+ columns.map { column_labels.fetch(it) }
85
+ end
86
+
87
+ def each_row(&)
88
+ row_source.call(&)
89
+ end
90
+
91
+ private
92
+
93
+ attr_reader :row_source
94
+
95
+ def validate!
96
+ validate_name!
97
+ validate_columns!
98
+ validate_labels!
99
+ validate_row_source!
100
+ end
101
+
102
+ def validate_name!
103
+ raise ArgumentError, "invalid export record-set name" unless name.match?(KEY_FORMAT)
104
+ end
105
+
106
+ def validate_columns!
107
+ raise ArgumentError, "export columns must be unique" unless columns.uniq.size == columns.size
108
+ end
109
+
110
+ def validate_labels!
111
+ raise ArgumentError, "export column labels must match columns" unless column_labels.keys == columns
112
+ raise ArgumentError, "export column labels must be unique" unless headers.uniq.size == headers.size
113
+ raise ArgumentError, "export column labels must be present" if headers.any? { it.to_s.strip.empty? }
114
+ end
115
+
116
+ def validate_row_source!
117
+ raise ArgumentError, "export row source must be callable" unless row_source.respond_to?(:call)
118
+ end
119
+ end
120
+
121
+ def initialize(identifier:, schema_version:, title:, metadata:, sections:)
122
+ @identifier = Document.string(identifier)
123
+ @schema_version = Integer(schema_version)
124
+ @title = Document.string(title)
125
+ @metadata = Document.normalize_metadata(metadata)
126
+ @sections = Array(sections).dup.freeze
127
+ validate!
128
+ end
129
+
130
+ def envelope(manifest)
131
+ { "format" => identifier, "schema_version" => schema_version, **metadata, "manifest" => manifest }
132
+ end
133
+
134
+ private
135
+
136
+ def validate!
137
+ Document.validate_metadata!(metadata, %w(format schema_version manifest data))
138
+ validate_identifier!
139
+ validate_schema_version!
140
+ validate_title!
141
+ validate_sections!
142
+ end
143
+
144
+ def validate_identifier!
145
+ raise ArgumentError, "invalid export document identifier" unless identifier.match?(IDENTIFIER_FORMAT)
146
+ end
147
+
148
+ def validate_schema_version!
149
+ raise ArgumentError, "invalid export schema version" unless schema_version.positive?
150
+ end
151
+
152
+ def validate_title!
153
+ raise ArgumentError, "export document title is required" if title.strip.empty?
154
+ end
155
+
156
+ def validate_sections!
157
+ unless sections.all?(Section)
158
+ raise ArgumentError, "export document sections must be ExportKit::Document::Section instances"
159
+ end
160
+ return if sections.map(&:key).uniq.size == sections.size
161
+
162
+ raise ArgumentError, "export document needs unique sections"
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExportKit
4
+ module FormatRegistry
5
+ Definition = Data.define(:key, :extension, :content_type)
6
+ DEFINITIONS = [
7
+ Definition.new(key: "json", extension: "json", content_type: "application/json"),
8
+ Definition.new(key: "csv", extension: "csv", content_type: "text/csv"),
9
+ Definition.new(key: "csv_zip", extension: "zip", content_type: "application/zip"),
10
+ Definition.new(key: "pdf", extension: "pdf", content_type: "application/pdf")
11
+ ].to_h { [it.key, it] }.freeze
12
+
13
+ def self.find(key) = DEFINITIONS[key.to_s]
14
+ end
15
+ end