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.
@@ -0,0 +1,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "digest"
5
+ require "zip"
6
+
7
+ module ExportKit
8
+ # Generic verification of a rendered artifact against the Document that produced it. A concrete
9
+ # workflow may check a few domain identifiers before calling this class, but never reimplements
10
+ # digest, manifest or JSON/CSV/PDF validation.
11
+ class ValidateArtifact
12
+ INVALID = :export_artifact_invalid
13
+ PLAIN_VALUES = [String, Integer, Float, TrueClass, FalseClass, NilClass].freeze
14
+
15
+ def self.call(**args) = new(**args).call
16
+
17
+ def initialize(built:, failure_code: INVALID)
18
+ @built = built
19
+ @failure_code = failure_code
20
+ end
21
+
22
+ def call
23
+ valid = digest_matches? && valid_contents?
24
+ valid ? success(built) : failure(failure_code)
25
+ rescue JSON::ParserError, CSV::MalformedCSVError, Zip::Error, EncodingError, Errno::ENOENT, IOError
26
+ failure(failure_code)
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :built, :failure_code
32
+
33
+ def document = built.document
34
+
35
+ def success(value) = Result.success(value)
36
+ def failure(code) = Result.failure(code)
37
+
38
+ def digest_matches?
39
+ built.file.path && File.size(built.file.path) == built.byte_size &&
40
+ Digest::SHA256.file(built.file.path).hexdigest == built.sha256
41
+ end
42
+
43
+ def valid_contents?
44
+ return valid_simple_contents? unless document.is_a?(Document)
45
+
46
+ valid_artifact_metadata? && valid_manifest? && valid_format?
47
+ end
48
+
49
+ def valid_simple_contents?
50
+ case built.format
51
+ when "json" then valid_simple_json?
52
+ when "csv" then valid_simple_csv?
53
+ when "pdf" then valid_pdf?
54
+ else false
55
+ end
56
+ end
57
+
58
+ def valid_simple_json?
59
+ value = JSON.parse(File.read(built.file.path))
60
+ !document.is_a?(Table) || (value.is_a?(Array) && value.all? { valid_record?(it, document) })
61
+ end
62
+
63
+ def valid_simple_csv?
64
+ return false unless document.is_a?(Table)
65
+
66
+ table = CSV.parse(File.read(built.file.path), headers: true)
67
+ table.headers == expected_csv_headers(document) && table.all? { it.size == document.columns.size }
68
+ end
69
+
70
+ def valid_artifact_metadata?
71
+ definition = ExportKit::FormatRegistry.find(built.format == "csv" ? "csv_zip" : built.format)
72
+ return false unless definition
73
+
74
+ actual = [built.identifier, built.schema_version, built.content_type, built.extension, built.metadata]
75
+ expected = [document.identifier, document.schema_version, definition.content_type, definition.extension,
76
+ document.metadata]
77
+ actual == expected
78
+ end
79
+
80
+ def valid_manifest?
81
+ return false unless built.manifest.is_a?(Array) && built.manifest.size == document.sections.size
82
+
83
+ document.sections.zip(built.manifest).all? { |section, entry| valid_manifest_entry?(section, entry) }
84
+ end
85
+
86
+ def valid_manifest_entry?(section, entry)
87
+ return false unless entry.is_a?(Hash)
88
+
89
+ counts = entry["record_sets"]
90
+ valid_manifest_counts?(section, counts) && entry == expected_manifest_entry(section, counts)
91
+ end
92
+
93
+ def expected_manifest_entry(section, counts)
94
+ {
95
+ "category" => section.key, "label" => section.label, "description" => section.description,
96
+ **section.metadata, "record_sets" => counts, "count" => counts&.values&.sum
97
+ }
98
+ end
99
+
100
+ def valid_manifest_counts?(section, counts)
101
+ counts.is_a?(Hash) && counts.keys == section.record_sets.map(&:name) &&
102
+ counts.values.all? { it.is_a?(Integer) && it >= 0 }
103
+ end
104
+
105
+ def valid_format?
106
+ case built.format
107
+ when "json" then valid_json?
108
+ when "csv_zip", "csv" then valid_csv_archive?
109
+ when "pdf" then valid_pdf?
110
+ else false
111
+ end
112
+ end
113
+
114
+ def valid_json?
115
+ document = JSON.parse(File.read(built.file.path))
116
+ valid_json_shape?(document) && valid_json_envelope?(document) &&
117
+ document["manifest"] == built.manifest && valid_json_data?(document["data"])
118
+ end
119
+
120
+ def valid_json_shape?(document)
121
+ expected_keys = (built.metadata.keys + %w(format schema_version manifest data)).sort
122
+ document.is_a?(Hash) && document.keys.sort == expected_keys
123
+ end
124
+
125
+ def valid_json_envelope?(document)
126
+ document.except("format", "schema_version", "manifest", "data") == built.metadata &&
127
+ document["format"] == built.identifier && document["schema_version"] == built.schema_version
128
+ end
129
+
130
+ def valid_json_data?(data)
131
+ categories = document.sections.map(&:key)
132
+ return false unless data.is_a?(Hash) && data.keys == categories
133
+
134
+ built.manifest.all? { |entry| valid_json_category?(data.fetch(entry["category"]), entry) }
135
+ end
136
+
137
+ def valid_json_category?(sets, entry)
138
+ record_sets = section(entry.fetch("category")).record_sets
139
+ return false unless sets.is_a?(Hash) && sets.keys == record_sets.map(&:name) && counted?(sets, entry)
140
+
141
+ record_sets.all? { |record_set| sets.fetch(record_set.name).all? { valid_record?(it, record_set) } }
142
+ end
143
+
144
+ def counted?(sets, entry)
145
+ entry["record_sets"] == sets.transform_values { it.is_a?(Array) ? it.size : -1 } &&
146
+ entry["count"] == entry["record_sets"].values.sum
147
+ end
148
+
149
+ def valid_record?(record, record_set)
150
+ record.is_a?(Hash) && record.keys == record_set.columns && record.values.all? { plain?(it) }
151
+ end
152
+
153
+ def plain?(value)
154
+ return value.all? { |item| PLAIN_VALUES.any? { item.is_a?(it) } } if value.is_a?(Array)
155
+
156
+ PLAIN_VALUES.any? { value.is_a?(it) }
157
+ end
158
+
159
+ def valid_csv_archive?
160
+ Zip::File.open(built.file.path) do |archive|
161
+ next false unless valid_csv_entries?(archive)
162
+ next false unless valid_csv_manifest?(csv_entry_contents(archive, "manifest.csv"))
163
+
164
+ valid_csv_record_sets?(archive)
165
+ end
166
+ end
167
+
168
+ def valid_csv_entries?(archive)
169
+ names = archive.entries.map(&:name)
170
+ names.sort == ["manifest.csv", *expected_csv_files].sort && names.all? { safe_zip_path?(it) }
171
+ end
172
+
173
+ def expected_csv_files
174
+ built.manifest.flat_map do |entry|
175
+ entry.fetch("record_sets").filter_map do |record_set, count|
176
+ "data/#{entry.fetch('category')}/#{record_set}.csv" if count.positive?
177
+ end
178
+ end
179
+ end
180
+
181
+ def safe_zip_path?(name)
182
+ name == "manifest.csv" || name.match?(%r{\Adata/[a-z][a-z0-9_]*/[a-z][a-z0-9_]*\.csv\z})
183
+ end
184
+
185
+ def valid_csv_manifest?(contents)
186
+ rows = CSV.parse(contents, headers: true)
187
+ return false unless rows.headers == ExportKit::Renderers::CsvArchive::MANIFEST_COLUMNS
188
+ return false unless rows.size == expected_csv_manifest_rows
189
+
190
+ valid_csv_document_manifest?(rows) && valid_csv_section_manifest?(rows) && valid_csv_record_set_manifest?(rows)
191
+ end
192
+
193
+ def expected_csv_manifest_rows
194
+ 3 + built.metadata.size + document.sections.sum { 1 + it.record_sets.size }
195
+ end
196
+
197
+ def valid_csv_document_manifest?(rows)
198
+ expected = expected_csv_document_metadata
199
+ entries = csv_document_metadata_entries(rows)
200
+
201
+ valid_csv_document_entries?(entries, expected) && entries.reduce({}, :merge) == expected
202
+ end
203
+
204
+ def expected_csv_document_metadata
205
+ { "format" => built.identifier, "schema_version" => built.schema_version,
206
+ "title" => document.title, **built.metadata }
207
+ end
208
+
209
+ def csv_document_metadata_entries(rows)
210
+ rows.filter_map { JSON.parse(it["metadata"]) if it["kind"] == "document" }
211
+ end
212
+
213
+ def valid_csv_document_entries?(entries, expected)
214
+ entries.all? { it.is_a?(Hash) && it.one? } && entries.flat_map(&:keys).sort == expected.keys.sort
215
+ end
216
+
217
+ def valid_csv_section_manifest?(rows)
218
+ built.manifest.all? do |entry|
219
+ row = sole_manifest_row(rows, "section", entry.fetch("category"))
220
+ row && row["count"].to_i == entry.fetch("count") &&
221
+ JSON.parse(row["metadata"]) == entry.except("category", "label", "description", "record_sets", "count")
222
+ end
223
+ end
224
+
225
+ def valid_csv_record_set_manifest?(rows)
226
+ built.manifest.all? do |entry|
227
+ section(entry.fetch("category")).record_sets.all? { valid_csv_record_set_manifest_row?(rows, entry, it) }
228
+ end
229
+ end
230
+
231
+ def valid_csv_record_set_manifest_row?(rows, entry, record_set)
232
+ row = sole_manifest_row(rows, "record_set", entry.fetch("category"), record_set.name)
233
+ count = entry.fetch("record_sets").fetch(record_set.name)
234
+ row && row["file"] == expected_csv_file(entry, record_set, count) && row["count"].to_i == count &&
235
+ JSON.parse(row["metadata"]) == expected_csv_record_set_metadata(record_set)
236
+ end
237
+
238
+ def expected_csv_file(entry, record_set, count)
239
+ "data/#{entry.fetch('category')}/#{record_set.name}.csv" if count.positive?
240
+ end
241
+
242
+ def expected_csv_record_set_metadata(record_set)
243
+ { "columns" => record_set.columns, "headers" => expected_csv_headers(record_set) }
244
+ end
245
+
246
+ def sole_manifest_row(rows, kind, section, record_set = nil)
247
+ matches = rows.select do |row|
248
+ row.values_at("kind", "section", "record_set") == [kind, section, record_set]
249
+ end
250
+ matches.first if matches.one?
251
+ end
252
+
253
+ def valid_csv_record_sets?(archive)
254
+ built.manifest.all? do |entry|
255
+ section(entry.fetch("category")).record_sets.all? { valid_csv_record_set?(archive, entry, it) }
256
+ end
257
+ end
258
+
259
+ def valid_csv_record_set?(archive, entry, record_set)
260
+ count = entry.fetch("record_sets").fetch(record_set.name)
261
+ return count.zero? unless count.positive?
262
+
263
+ path = "data/#{entry.fetch('category')}/#{record_set.name}.csv"
264
+ table = CSV.parse(csv_entry_contents(archive, path), headers: true)
265
+ table.headers == expected_csv_headers(record_set) && table.size == count
266
+ end
267
+
268
+ def csv_entry_contents(archive, path)
269
+ utf8(archive.find_entry(path).get_input_stream.read)
270
+ end
271
+
272
+ def expected_csv_headers(record_set)
273
+ record_set.headers.map { Renderers::CsvArchive.csv_value(it) }
274
+ end
275
+
276
+ def section(key)
277
+ document.sections.find { it.key == key }
278
+ end
279
+
280
+ def valid_pdf?
281
+ File.open(built.file.path, "rb") do |file|
282
+ file.read(8)&.start_with?("%PDF-") && begin
283
+ file.seek(-[file.size, 1024].min, IO::SEEK_END)
284
+ file.read.include?("%%EOF")
285
+ end
286
+ end
287
+ end
288
+
289
+ def utf8(contents)
290
+ encoded = contents.force_encoding(Encoding::UTF_8)
291
+ raise EncodingError, "export CSV is not UTF-8" unless encoded.valid_encoding?
292
+
293
+ encoded
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ExportKit
4
+ VERSION = "0.1.0"
5
+ end
data/lib/export_kit.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "csv"
5
+ require "digest"
6
+ require "tempfile"
7
+ require "export_kit/version"
8
+ require "export_kit/result"
9
+ require "export_kit/document"
10
+ require "export_kit/table"
11
+ require "export_kit/renderers/base"
12
+ require "export_kit/output"
13
+ require "export_kit/renderers/json"
14
+ require "export_kit/renderers/csv_archive"
15
+ require "export_kit/renderers/pdf"
16
+ require "export_kit/format_registry"
17
+ require "export_kit/validate_artifact"
18
+ require "export_kit/artifact"
19
+ require "export_kit/render"
20
+
21
+ module ExportKit
22
+ def self.render(data, **args) = Render.new(data:, **args).call
23
+
24
+ def self.validate(artifact, failure_code: ValidateArtifact::INVALID)
25
+ ValidateArtifact.call(built: artifact, failure_code:)
26
+ end
27
+ end
metadata ADDED
@@ -0,0 +1,134 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: export_kit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Łukasz Kerl
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: csv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 3.3.6
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 3.3.6
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4'
32
+ - !ruby/object:Gem::Dependency
33
+ name: json
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 2.21.2
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '4'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 2.21.2
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '4'
52
+ - !ruby/object:Gem::Dependency
53
+ name: prawn
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - "~>"
57
+ - !ruby/object:Gem::Version
58
+ version: '2.5'
59
+ type: :runtime
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - "~>"
64
+ - !ruby/object:Gem::Version
65
+ version: '2.5'
66
+ - !ruby/object:Gem::Dependency
67
+ name: rubyzip
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '3.6'
73
+ type: :runtime
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '3.6'
80
+ description: Builds streamed, size-bounded export artifacts with explicit schemas,
81
+ safe CSV cells, structural validation and an optional Active Record adapter.
82
+ executables: []
83
+ extensions: []
84
+ extra_rdoc_files: []
85
+ files:
86
+ - CHANGELOG.md
87
+ - LICENSE.txt
88
+ - README.md
89
+ - README.pl.md
90
+ - lib/export_kit.rb
91
+ - lib/export_kit/active_record.rb
92
+ - lib/export_kit/artifact.rb
93
+ - lib/export_kit/document.rb
94
+ - lib/export_kit/format_registry.rb
95
+ - lib/export_kit/model_rows.rb
96
+ - lib/export_kit/model_schema.rb
97
+ - lib/export_kit/model_serializer.rb
98
+ - lib/export_kit/output.rb
99
+ - lib/export_kit/render.rb
100
+ - lib/export_kit/renderers/base.rb
101
+ - lib/export_kit/renderers/csv_archive.rb
102
+ - lib/export_kit/renderers/json.rb
103
+ - lib/export_kit/renderers/pdf.rb
104
+ - lib/export_kit/result.rb
105
+ - lib/export_kit/table.rb
106
+ - lib/export_kit/validate_artifact.rb
107
+ - lib/export_kit/version.rb
108
+ homepage: https://github.com/carlos-1410/export-kit
109
+ licenses:
110
+ - MIT
111
+ metadata:
112
+ homepage_uri: https://github.com/carlos-1410/export-kit
113
+ source_code_uri: https://github.com/carlos-1410/export-kit/tree/v0.1.0
114
+ changelog_uri: https://github.com/carlos-1410/export-kit/blob/v0.1.0/CHANGELOG.md
115
+ allowed_push_host: https://rubygems.org
116
+ rubygems_mfa_required: 'true'
117
+ rdoc_options: []
118
+ require_paths:
119
+ - lib
120
+ required_ruby_version: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: 3.4.0
125
+ required_rubygems_version: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '0'
130
+ requirements: []
131
+ rubygems_version: 3.6.9
132
+ specification_version: 4
133
+ summary: Portable, size-bounded JSON, CSV and PDF exports
134
+ test_files: []