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.
@@ -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 'output_capture'
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
- @timeout = timeout
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 = execute(argv)
19
+ result = @runner.call(argv)
29
20
  return result if result.exit_status.zero?
30
21
 
31
22
  raise_failed(result)
32
- rescue CompositionError
33
- raise
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 wait_for(wait_thread, stderr_reader)
82
- Timeout.timeout(@timeout) { wait_thread.value }
83
- rescue Timeout::Error
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 timed out after #{@timeout} seconds",
87
- timeout: @timeout,
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 capture(stream)
93
- Thread.new { OutputCapture.new(stream, limit: @output_limit).call }
94
- end
95
-
96
- def terminate(wait_thread)
97
- signal('TERM', wait_thread.pid)
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 InvalidXmlError < Error; end
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
@@ -1,21 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'attach'
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, attacher: Attach.new, profile_resolver: ProfileResolver.new)
9
+ def initialize(writer: Writer.new, pdf_composer: Pdf::Composer.new, profile_resolver: ProfileResolver.new)
10
10
  @writer = writer
11
- @attacher = attacher
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
- @attacher.call(pdf:, xml:)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Facturx
4
- Profile = Data.define(:id, :guideline_urn, :xsd_path, :conformance_level)
4
+ Profile = Data.define(:id, :guideline_urn, :conformance_level)
5
5
  end
@@ -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 InvalidXmlError.new('Source must be provided as a byte String', input_class: source.class.name)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Facturx
4
- VERSION = '1.0.0'
4
+ VERSION = '1.2.0'
5
5
  end