facturx 1.0.0 → 1.2.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 +38 -1
- data/DOCUMENTATION.md +302 -0
- data/README.md +71 -89
- data/lib/facturx/attach.rb +4 -20
- data/lib/facturx/composers/ghostscript/runner.rb +19 -80
- data/lib/facturx/error.rb +6 -2
- data/lib/facturx/generate.rb +4 -4
- data/lib/facturx/pdf/composer.rb +32 -0
- data/lib/facturx/profile.rb +1 -1
- data/lib/facturx/profiles.rb +1 -8
- data/lib/facturx/schematron_adapter.rb +30 -0
- data/lib/facturx/source_reader.rb +1 -1
- data/lib/facturx/subprocess.rb +154 -0
- data/lib/facturx/validation.rb +61 -0
- data/lib/facturx/version.rb +1 -1
- data/lib/facturx/{conformance.rb → writer/tracker.rb} +53 -85
- data/lib/facturx/writer.rb +22 -14
- data/lib/facturx/xml/conformance_validator.rb +20 -0
- data/lib/facturx/xml/parser.rb +1 -1
- data/lib/facturx/xml/report_builder.rb +44 -0
- data/lib/facturx/xml/schema_registry.rb +22 -0
- data/lib/facturx/xml/schema_validator.rb +7 -2
- data/lib/facturx/xml/validator.rb +36 -0
- data/lib/facturx/xml/verifier.rb +17 -11
- data/lib/facturx.rb +18 -6
- data/rbi/facturx.rbi +702 -36
- metadata +11 -3
- data/lib/facturx/composers/ghostscript/output_capture.rb +0 -39
|
@@ -1,74 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'open3'
|
|
4
|
-
require 'timeout'
|
|
5
3
|
require_relative '../../error'
|
|
6
|
-
require_relative '
|
|
4
|
+
require_relative '../../subprocess'
|
|
7
5
|
|
|
8
6
|
module Facturx
|
|
9
7
|
module Composers
|
|
10
8
|
class Ghostscript
|
|
11
9
|
class Runner
|
|
12
|
-
Result = Data.define(:stdout, :stderr, :exit_status)
|
|
13
|
-
|
|
14
|
-
DEFAULT_TIMEOUT = 60
|
|
15
|
-
DEFAULT_OUTPUT_LIMIT = 8_192
|
|
16
|
-
DEFAULT_TERMINATION_GRACE = 1
|
|
17
10
|
def initialize(
|
|
18
|
-
timeout: DEFAULT_TIMEOUT,
|
|
19
|
-
output_limit: DEFAULT_OUTPUT_LIMIT,
|
|
20
|
-
termination_grace: DEFAULT_TERMINATION_GRACE
|
|
11
|
+
timeout: Subprocess::Runner::DEFAULT_TIMEOUT,
|
|
12
|
+
output_limit: Subprocess::Runner::DEFAULT_OUTPUT_LIMIT,
|
|
13
|
+
termination_grace: Subprocess::Runner::DEFAULT_TERMINATION_GRACE
|
|
21
14
|
)
|
|
22
|
-
@
|
|
23
|
-
@output_limit = output_limit
|
|
24
|
-
@termination_grace = termination_grace
|
|
15
|
+
@runner = Subprocess::Runner.new(timeout:, output_limit:, termination_grace:)
|
|
25
16
|
end
|
|
26
17
|
|
|
27
18
|
def call(argv)
|
|
28
|
-
result =
|
|
19
|
+
result = @runner.call(argv)
|
|
29
20
|
return result if result.exit_status.zero?
|
|
30
21
|
|
|
31
22
|
raise_failed(result)
|
|
32
|
-
rescue
|
|
33
|
-
|
|
34
|
-
rescue SystemCallError => e
|
|
35
|
-
raise CompositionError.new(
|
|
36
|
-
"Ghostscript could not be executed: #{e.message}",
|
|
37
|
-
cause: e.class.name
|
|
38
|
-
)
|
|
23
|
+
rescue Subprocess::Error => e
|
|
24
|
+
raise_process_error(e)
|
|
39
25
|
end
|
|
40
26
|
|
|
41
27
|
private
|
|
42
28
|
|
|
43
|
-
def execute(argv)
|
|
44
|
-
Open3.popen3(*argv, pgroup: true) do |stdin, stdout, stderr, wait_thread|
|
|
45
|
-
capture_process(stdin, stdout, stderr, wait_thread)
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def capture_process(stdin, stdout, stderr, wait_thread)
|
|
50
|
-
stdin.close
|
|
51
|
-
stdout_reader = capture(stdout)
|
|
52
|
-
stderr_reader = capture(stderr)
|
|
53
|
-
build_result(wait_thread, stdout_reader, stderr_reader)
|
|
54
|
-
ensure
|
|
55
|
-
stdout_reader&.join
|
|
56
|
-
stderr_reader&.join
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def build_result(wait_thread, stdout_reader, stderr_reader)
|
|
60
|
-
status = wait_for(wait_thread, stderr_reader)
|
|
61
|
-
Result.new(
|
|
62
|
-
stdout: stdout_reader.value,
|
|
63
|
-
stderr: stderr_reader.value,
|
|
64
|
-
exit_status: exit_status(status)
|
|
65
|
-
)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def exit_status(status)
|
|
69
|
-
status.exitstatus || (128 + status.termsig)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
29
|
def raise_failed(result)
|
|
73
30
|
raise CompositionError.new(
|
|
74
31
|
"Ghostscript failed with exit status #{result.exit_status}",
|
|
@@ -78,39 +35,21 @@ module Facturx
|
|
|
78
35
|
)
|
|
79
36
|
end
|
|
80
37
|
|
|
81
|
-
def
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
terminate(wait_thread)
|
|
38
|
+
def raise_process_error(error)
|
|
39
|
+
return raise_timeout(error) if error.details[:reason] == :timeout
|
|
40
|
+
|
|
85
41
|
raise CompositionError.new(
|
|
86
|
-
"Ghostscript
|
|
87
|
-
|
|
88
|
-
stderr: sanitize(stderr_reader.value)
|
|
42
|
+
"Ghostscript could not be executed: #{error.details[:message]}",
|
|
43
|
+
cause: error.details[:cause]
|
|
89
44
|
)
|
|
90
45
|
end
|
|
91
46
|
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return if wait_thread.join(@termination_grace)
|
|
99
|
-
|
|
100
|
-
signal('KILL', wait_thread.pid)
|
|
101
|
-
wait_thread.join
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
def signal(name, pid)
|
|
105
|
-
Process.kill(name, -pid)
|
|
106
|
-
rescue Errno::ESRCH
|
|
107
|
-
nil
|
|
108
|
-
rescue SystemCallError
|
|
109
|
-
begin
|
|
110
|
-
Process.kill(name, pid)
|
|
111
|
-
rescue Errno::ESRCH
|
|
112
|
-
nil
|
|
113
|
-
end
|
|
47
|
+
def raise_timeout(error)
|
|
48
|
+
raise CompositionError.new(
|
|
49
|
+
"Ghostscript timed out after #{error.details[:timeout]} seconds",
|
|
50
|
+
timeout: error.details[:timeout],
|
|
51
|
+
stderr: sanitize(error.details[:stderr])
|
|
52
|
+
)
|
|
114
53
|
end
|
|
115
54
|
|
|
116
55
|
def sanitize(output)
|
data/lib/facturx/error.rb
CHANGED
|
@@ -10,12 +10,16 @@ module Facturx
|
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
class
|
|
13
|
+
class ValidationError < Error; end
|
|
14
|
+
class InvalidDocumentError < ValidationError; end
|
|
15
|
+
class InvalidXmlError < ValidationError; end
|
|
14
16
|
class UnknownProfileError < InvalidXmlError; end
|
|
15
17
|
class XsdValidationError < InvalidXmlError; end
|
|
18
|
+
class SchematronValidationError < InvalidXmlError; end
|
|
19
|
+
class SchemaLoadError < Error; end
|
|
20
|
+
class InvalidSourceError < Error; end
|
|
16
21
|
class UnsupportedProfileError < Error; end
|
|
17
22
|
class FormattingError < Error; end
|
|
18
|
-
class ConformanceError < Error; end
|
|
19
23
|
class InvalidPdfError < Error; end
|
|
20
24
|
class ProtectedPdfError < InvalidPdfError; end
|
|
21
25
|
class ComposerUnavailableError < Error; end
|
data/lib/facturx/generate.rb
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative '
|
|
3
|
+
require_relative 'pdf/composer'
|
|
4
4
|
require_relative 'profile_resolver'
|
|
5
5
|
require_relative 'writer'
|
|
6
6
|
|
|
7
7
|
module Facturx
|
|
8
8
|
class Generate
|
|
9
|
-
def initialize(writer: Writer.new,
|
|
9
|
+
def initialize(writer: Writer.new, pdf_composer: Pdf::Composer.new, profile_resolver: ProfileResolver.new)
|
|
10
10
|
@writer = writer
|
|
11
|
-
@
|
|
11
|
+
@pdf_composer = pdf_composer
|
|
12
12
|
@profile_resolver = profile_resolver
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def call(pdf:, document:, profile:)
|
|
16
16
|
canonical_profile = @profile_resolver.call(profile)
|
|
17
17
|
xml = @writer.call(document:, profile: canonical_profile)
|
|
18
|
-
@
|
|
18
|
+
@pdf_composer.call(pdf:, xml:, profile: canonical_profile)
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'inspector'
|
|
4
|
+
require_relative 'extractor'
|
|
5
|
+
require_relative 'verifier'
|
|
6
|
+
require_relative '../composers/ghostscript'
|
|
7
|
+
|
|
8
|
+
module Facturx
|
|
9
|
+
module Pdf
|
|
10
|
+
class Composer
|
|
11
|
+
def initialize(
|
|
12
|
+
inspector: Inspector.new,
|
|
13
|
+
backend: Composers::Ghostscript.new,
|
|
14
|
+
extractor: Extractor.new,
|
|
15
|
+
verifier: Verifier.new
|
|
16
|
+
)
|
|
17
|
+
@inspector = inspector
|
|
18
|
+
@backend = backend
|
|
19
|
+
@extractor = extractor
|
|
20
|
+
@verifier = verifier
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(pdf:, xml:, profile:)
|
|
24
|
+
inspection = @inspector.call(pdf)
|
|
25
|
+
output = @backend.call(pdf:, xml:, profile:)
|
|
26
|
+
result = @extractor.call(output)
|
|
27
|
+
@verifier.call(result:, expected_xml: xml, expected_page_count: inspection.page_count, profile:)
|
|
28
|
+
output
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/facturx/profile.rb
CHANGED
data/lib/facturx/profiles.rb
CHANGED
|
@@ -4,44 +4,37 @@ require_relative 'profile'
|
|
|
4
4
|
|
|
5
5
|
module Facturx
|
|
6
6
|
module Profiles
|
|
7
|
-
SPECIFICATION_VERSION = '1.09.2'
|
|
8
|
-
SCHEMA_ROOT = File.expand_path("schema/#{SPECIFICATION_VERSION}", __dir__).freeze
|
|
9
|
-
|
|
10
7
|
ALL = [
|
|
11
8
|
Profile.new(
|
|
12
9
|
id: :minimum,
|
|
13
10
|
guideline_urn: 'urn:factur-x.eu:1p0:minimum',
|
|
14
|
-
xsd_path: File.join(SCHEMA_ROOT, 'minimum/Factur-X_1.09.2_MINIMUM.xsd').freeze,
|
|
15
11
|
conformance_level: 'MINIMUM'
|
|
16
12
|
),
|
|
17
13
|
Profile.new(
|
|
18
14
|
id: :basic_wl,
|
|
19
15
|
guideline_urn: 'urn:factur-x.eu:1p0:basicwl',
|
|
20
|
-
xsd_path: File.join(SCHEMA_ROOT, 'basic-wl/Factur-X_1.09.2_BASICWL.xsd').freeze,
|
|
21
16
|
conformance_level: 'BASIC WL'
|
|
22
17
|
),
|
|
23
18
|
Profile.new(
|
|
24
19
|
id: :basic,
|
|
25
20
|
guideline_urn: 'urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic',
|
|
26
|
-
xsd_path: File.join(SCHEMA_ROOT, 'basic/Factur-X_1.09.2_BASIC.xsd').freeze,
|
|
27
21
|
conformance_level: 'BASIC'
|
|
28
22
|
),
|
|
29
23
|
Profile.new(
|
|
30
24
|
id: :en16931,
|
|
31
25
|
guideline_urn: 'urn:cen.eu:en16931:2017',
|
|
32
|
-
xsd_path: File.join(SCHEMA_ROOT, 'en16931/Factur-X_1.09.2_EN16931.xsd').freeze,
|
|
33
26
|
conformance_level: 'EN 16931'
|
|
34
27
|
),
|
|
35
28
|
Profile.new(
|
|
36
29
|
id: :extended,
|
|
37
30
|
guideline_urn: 'urn:cen.eu:en16931:2017#conformant#urn:factur-x.eu:1p0:extended',
|
|
38
|
-
xsd_path: File.join(SCHEMA_ROOT, 'extended/Factur-X_1.09.2_EXTENDED.xsd').freeze,
|
|
39
31
|
conformance_level: 'EXTENDED'
|
|
40
32
|
)
|
|
41
33
|
].freeze
|
|
42
34
|
|
|
43
35
|
BY_ID = ALL.to_h { |profile| [profile.id, profile] }.freeze
|
|
44
36
|
BY_GUIDELINE_URN = ALL.to_h { |profile| [profile.guideline_urn, profile] }.freeze
|
|
37
|
+
private_constant :ALL, :BY_ID, :BY_GUIDELINE_URN
|
|
45
38
|
|
|
46
39
|
module_function
|
|
47
40
|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Facturx
|
|
4
|
+
module SchematronAdapter
|
|
5
|
+
class NullValidator
|
|
6
|
+
def call(**)
|
|
7
|
+
[]
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@validator = NullValidator.new.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def install(validator)
|
|
16
|
+
raise TypeError, 'Schematron validator must respond to call' unless validator.respond_to?(:call)
|
|
17
|
+
|
|
18
|
+
@mutex.synchronize { @validator = validator }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(document:, profile:)
|
|
22
|
+
@validator.call(document:, profile:)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private_constant :NullValidator
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private_constant :SchematronAdapter
|
|
30
|
+
end
|
|
@@ -13,7 +13,7 @@ module Facturx
|
|
|
13
13
|
|
|
14
14
|
def call(source)
|
|
15
15
|
unless source.is_a?(String)
|
|
16
|
-
raise
|
|
16
|
+
raise InvalidSourceError.new('Source must be provided as a byte String', input_class: source.class.name)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
return Result.new(xml: source, source_type: :xml) unless pdf?(source)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
require_relative 'error'
|
|
6
|
+
|
|
7
|
+
module Facturx
|
|
8
|
+
module Subprocess
|
|
9
|
+
Result = Data.define(:stdout, :stderr, :exit_status, :stdout_truncated)
|
|
10
|
+
|
|
11
|
+
class Error < Facturx::Error; end
|
|
12
|
+
|
|
13
|
+
class OutputCapture
|
|
14
|
+
TRUNCATION_MARKER = '...[truncated]'
|
|
15
|
+
Output = Data.define(:content, :truncated)
|
|
16
|
+
|
|
17
|
+
def initialize(stream, limit:)
|
|
18
|
+
@stream = stream
|
|
19
|
+
@limit = limit
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
buffer = +''.b
|
|
24
|
+
truncated = false
|
|
25
|
+
while (chunk = @stream.read(4_096))
|
|
26
|
+
remaining = @limit - buffer.bytesize
|
|
27
|
+
buffer << chunk.byteslice(0, remaining) if remaining.positive?
|
|
28
|
+
truncated ||= chunk.bytesize > remaining
|
|
29
|
+
end
|
|
30
|
+
Output.new(content: truncate(buffer, truncated), truncated:)
|
|
31
|
+
rescue IOError
|
|
32
|
+
Output.new(content: buffer, truncated:)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def truncate(buffer, truncated)
|
|
38
|
+
return buffer unless truncated
|
|
39
|
+
|
|
40
|
+
marker = TRUNCATION_MARKER.byteslice(0, @limit)
|
|
41
|
+
kept_bytes = [@limit - marker.bytesize, 0].max
|
|
42
|
+
buffer.byteslice(0, kept_bytes).to_s << marker
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Runner
|
|
47
|
+
DEFAULT_TIMEOUT = 60
|
|
48
|
+
DEFAULT_OUTPUT_LIMIT = 8_192
|
|
49
|
+
DEFAULT_TERMINATION_GRACE = 1
|
|
50
|
+
|
|
51
|
+
def initialize(
|
|
52
|
+
timeout: DEFAULT_TIMEOUT,
|
|
53
|
+
output_limit: DEFAULT_OUTPUT_LIMIT,
|
|
54
|
+
termination_grace: DEFAULT_TERMINATION_GRACE
|
|
55
|
+
)
|
|
56
|
+
@timeout = timeout
|
|
57
|
+
@output_limit = output_limit
|
|
58
|
+
@termination_grace = termination_grace
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def call(argv, input: nil, chdir: nil)
|
|
62
|
+
execute(argv, input:, chdir:)
|
|
63
|
+
rescue SystemCallError => e
|
|
64
|
+
raise Error.new('Process could not be executed', reason: :spawn, cause: e.class.name, message: e.message)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def execute(argv, input:, chdir:)
|
|
70
|
+
options = { pgroup: true }
|
|
71
|
+
options[:chdir] = chdir if chdir
|
|
72
|
+
Open3.popen3(*argv, **options) do |stdin, stdout, stderr, wait_thread|
|
|
73
|
+
capture_process(stdin, stdout, stderr, wait_thread, input)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def capture_process(stdin, stdout, stderr, wait_thread, input)
|
|
78
|
+
stdin_writer = write(stdin, input)
|
|
79
|
+
stdout_reader = capture(stdout)
|
|
80
|
+
stderr_reader = capture(stderr)
|
|
81
|
+
build_result(wait_thread, stdout_reader, stderr_reader)
|
|
82
|
+
ensure
|
|
83
|
+
stdin_writer&.join
|
|
84
|
+
stdout_reader&.join
|
|
85
|
+
stderr_reader&.join
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def write(stdin, input)
|
|
89
|
+
Thread.new do
|
|
90
|
+
stdin.binmode
|
|
91
|
+
stdin.write(input) if input
|
|
92
|
+
rescue Errno::EPIPE, IOError
|
|
93
|
+
nil
|
|
94
|
+
ensure
|
|
95
|
+
stdin.close
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def capture(stream)
|
|
100
|
+
Thread.new { OutputCapture.new(stream, limit: @output_limit).call }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def build_result(wait_thread, stdout_reader, stderr_reader)
|
|
104
|
+
status = wait_for(wait_thread, stderr_reader)
|
|
105
|
+
stdout = stdout_reader.value
|
|
106
|
+
stderr = stderr_reader.value
|
|
107
|
+
Result.new(
|
|
108
|
+
stdout: stdout.content,
|
|
109
|
+
stderr: stderr.content,
|
|
110
|
+
exit_status: status.exitstatus || (128 + status.termsig),
|
|
111
|
+
stdout_truncated: stdout.truncated
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def wait_for(wait_thread, stderr_reader)
|
|
116
|
+
Timeout.timeout(@timeout) { wait_thread.value }
|
|
117
|
+
rescue Timeout::Error
|
|
118
|
+
terminate(wait_thread)
|
|
119
|
+
raise Error.new(
|
|
120
|
+
"Process timed out after #{@timeout} seconds",
|
|
121
|
+
reason: :timeout,
|
|
122
|
+
timeout: @timeout,
|
|
123
|
+
stderr: sanitize(stderr_reader.value.content)
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def terminate(wait_thread)
|
|
128
|
+
signal('TERM', wait_thread.pid)
|
|
129
|
+
return if wait_thread.join(@termination_grace)
|
|
130
|
+
|
|
131
|
+
signal('KILL', wait_thread.pid)
|
|
132
|
+
wait_thread.join
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def sanitize(output)
|
|
136
|
+
output.to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '?')
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def signal(name, pid)
|
|
140
|
+
Process.kill(name, -pid)
|
|
141
|
+
rescue Errno::ESRCH
|
|
142
|
+
nil
|
|
143
|
+
rescue SystemCallError
|
|
144
|
+
begin
|
|
145
|
+
Process.kill(name, pid)
|
|
146
|
+
rescue Errno::ESRCH
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private_constant :OutputCapture
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'error'
|
|
4
|
+
require_relative 'model/immutable'
|
|
5
|
+
|
|
6
|
+
module Facturx
|
|
7
|
+
module Validation
|
|
8
|
+
LAYERS = %i[syntax profile document xsd schematron].freeze
|
|
9
|
+
SEVERITIES = %i[error warning].freeze
|
|
10
|
+
ISSUE_DEFAULTS = {
|
|
11
|
+
severity: :error,
|
|
12
|
+
term_id: nil,
|
|
13
|
+
group_id: nil,
|
|
14
|
+
path: nil,
|
|
15
|
+
line: nil,
|
|
16
|
+
column: nil,
|
|
17
|
+
details: {}
|
|
18
|
+
}.freeze
|
|
19
|
+
private_constant :LAYERS, :SEVERITIES, :ISSUE_DEFAULTS
|
|
20
|
+
|
|
21
|
+
Issue = Data.define(:code, :message, :layer, :severity, :term_id, :group_id, :path, :line, :column, :details) do
|
|
22
|
+
include Model::ValidatedWith
|
|
23
|
+
|
|
24
|
+
def initialize(**attributes)
|
|
25
|
+
Model.validate_attributes!(self.class.members, attributes)
|
|
26
|
+
attributes.fetch_values(:code, :message, :layer)
|
|
27
|
+
values = ISSUE_DEFAULTS.merge(attributes)
|
|
28
|
+
validate_values!(values)
|
|
29
|
+
|
|
30
|
+
super(**values.transform_values { |value| Model.copy_and_freeze(value) })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def validate_values!(values)
|
|
36
|
+
unless LAYERS.include?(values[:layer])
|
|
37
|
+
raise ArgumentError, "Unknown validation layer: #{values[:layer].inspect}"
|
|
38
|
+
end
|
|
39
|
+
unless SEVERITIES.include?(values[:severity])
|
|
40
|
+
raise ArgumentError, "Unknown validation severity: #{values[:severity].inspect}"
|
|
41
|
+
end
|
|
42
|
+
raise TypeError, 'details must be a Hash' unless values[:details].is_a?(Hash)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Report = Data.define(:profile, :issues) do
|
|
47
|
+
include Model::ValidatedWith
|
|
48
|
+
|
|
49
|
+
def initialize(profile: nil, issues: [])
|
|
50
|
+
raise TypeError, 'issues must be an Array' unless issues.is_a?(Array)
|
|
51
|
+
raise TypeError, 'issues must contain validation issues' unless issues.all?(Issue)
|
|
52
|
+
|
|
53
|
+
super(profile: Model.copy_and_freeze(profile), issues: Model.copy_and_freeze(issues))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def valid? = issues.none? { |issue| issue.severity == :error }
|
|
57
|
+
|
|
58
|
+
def invalid? = !valid?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/facturx/version.rb
CHANGED