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,38 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'error'
4
- require_relative 'model/immutable'
3
+ require_relative '../error'
4
+ require_relative '../validation'
5
5
 
6
6
  module Facturx
7
- module Conformance
8
- Issue = Data.define(:code, :message, :term_id, :group_id, :path, :details) do
9
- include Model::ValidatedWith
7
+ class Writer
8
+ class Tracker
9
+ attr_reader :profile
10
+
11
+ def initialize(profile:)
12
+ @profile = profile
13
+ @issues = []
14
+ end
10
15
 
11
- def initialize(**attributes)
12
- Model.validate_attributes!(self.class.members, attributes)
13
- attributes.fetch_values(:code, :message)
14
- values = { term_id: nil, group_id: nil, path: nil, details: {} }.merge(attributes)
15
- raise TypeError, 'details must be a Hash' unless values[:details].is_a?(Hash)
16
+ def check_guideline(guideline_urn, path: nil)
17
+ return if guideline_urn.nil? || guideline_urn == profile.guideline_urn
16
18
 
17
- super(**values.transform_values { |value| Model.copy_and_freeze(value) })
19
+ add(
20
+ :profile_mismatch,
21
+ 'Document guideline does not match the selected profile',
22
+ term_id: 'BT-24',
23
+ path:,
24
+ details: { expected: profile.guideline_urn, actual: guideline_urn }
25
+ )
18
26
  end
19
- end
20
27
 
21
- Report = Data.define(:profile, :issues) do
22
- include Model::ValidatedWith
28
+ def check_document(document, path: nil)
29
+ check_guideline(document.guideline_urn, path:)
30
+ end
23
31
 
24
- def initialize(profile:, issues: [])
25
- raise TypeError, 'issues must be an Array' unless issues.is_a?(Array)
32
+ def observe_group?(group, count:, path: nil)
33
+ definition_observed?(group, count, path)
34
+ end
26
35
 
27
- super(profile: Model.copy_and_freeze(profile), issues: Model.copy_and_freeze(issues))
36
+ def observe_term?(term, values:, path: nil)
37
+ definition_observed?(term, values.size, path)
28
38
  end
29
39
 
30
- def valid? = issues.empty?
40
+ def invalid_term(term, error:, path: nil)
41
+ raise TypeError, 'error must be a FormattingError' unless error.is_a?(FormattingError)
31
42
 
32
- def invalid? = !valid?
33
- end
43
+ add(
44
+ :invalid_value,
45
+ error.message,
46
+ term_id: term.id,
47
+ group_id: term.group_id,
48
+ path: path || error.details[:path] || term.xpath,
49
+ details: error.details
50
+ )
51
+ end
52
+
53
+ def unrepresentable_attribute(group, model:, attribute:, path: nil)
54
+ add(
55
+ :unrepresentable_attribute,
56
+ 'Model attribute cannot be represented in this group',
57
+ group_id: group.id,
58
+ path: path || group.xpath,
59
+ details: { model:, attribute: }
60
+ )
61
+ end
62
+
63
+ def report
64
+ Validation::Report.new(profile:, issues: @issues)
65
+ end
34
66
 
35
- module Tracking
36
67
  private
37
68
 
38
69
  def definition_observed?(definition, count, path)
@@ -77,7 +108,7 @@ module Facturx
77
108
  end
78
109
 
79
110
  def add(code, message, **context)
80
- @issues << Issue.new(code:, message:, **context)
111
+ @issues << Validation::Issue.new(code:, message:, layer: :document, **context)
81
112
  end
82
113
 
83
114
  def definition_context(definition, path)
@@ -96,68 +127,5 @@ module Facturx
96
127
 
97
128
  def repeated?(cardinality) = cardinality.end_with?('n')
98
129
  end
99
- private_constant :Tracking
100
-
101
- class Tracker
102
- include Tracking
103
-
104
- attr_reader :profile
105
-
106
- def initialize(profile:)
107
- @profile = profile
108
- @issues = []
109
- end
110
-
111
- def check_guideline(guideline_urn, path: nil)
112
- return if guideline_urn.nil? || guideline_urn == profile.guideline_urn
113
-
114
- add(
115
- :profile_mismatch,
116
- 'Document guideline does not match the selected profile',
117
- term_id: 'BT-24',
118
- path:,
119
- details: { expected: profile.guideline_urn, actual: guideline_urn }
120
- )
121
- end
122
-
123
- def check_document(document, path: nil)
124
- check_guideline(document.guideline_urn, path:)
125
- end
126
-
127
- def observe_group?(group, count:, path: nil)
128
- definition_observed?(group, count, path)
129
- end
130
-
131
- def observe_term?(term, values:, path: nil)
132
- definition_observed?(term, values.size, path)
133
- end
134
-
135
- def invalid_term(term, error:, path: nil)
136
- raise TypeError, 'error must be a FormattingError' unless error.is_a?(FormattingError)
137
-
138
- add(
139
- :invalid_value,
140
- error.message,
141
- term_id: term.id,
142
- group_id: term.group_id,
143
- path: path || error.details[:path] || term.xpath,
144
- details: error.details
145
- )
146
- end
147
-
148
- def unrepresentable_attribute(group, model:, attribute:, path: nil)
149
- add(
150
- :unrepresentable_attribute,
151
- 'Model attribute cannot be represented in this group',
152
- group_id: group.id,
153
- path: path || group.xpath,
154
- details: { model:, attribute: }
155
- )
156
- end
157
-
158
- def report
159
- Report.new(profile:, issues: @issues)
160
- end
161
- end
162
130
  end
163
131
  end
@@ -1,17 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'conformance'
4
3
  require_relative 'error'
5
4
  require_relative 'terms'
6
5
  require_relative 'writer/context'
7
6
  require_relative 'writer/stage'
7
+ require_relative 'writer/tracker'
8
8
  require_relative 'writer/stages/document_context'
9
9
  require_relative 'writer/stages/exchanged_document'
10
10
  require_relative 'writer/stages/trade_lines'
11
11
  require_relative 'writer/stages/trade_agreement'
12
12
  require_relative 'writer/stages/trade_delivery'
13
13
  require_relative 'writer/stages/trade_settlement'
14
- require_relative 'xml/schema_validator'
14
+ require_relative 'xml/conformance_validator'
15
+ require_relative 'xml/report_builder'
15
16
 
16
17
  module Facturx
17
18
  class Writer
@@ -29,37 +30,44 @@ module Facturx
29
30
  raise 'Factur-X writer stages must cover every modeled term exactly once'
30
31
  end
31
32
 
32
- def initialize(schema_validator: Xml::SchemaValidator.new, tracker: Conformance::Tracker, stages: STAGES)
33
- @schema_validator = schema_validator
34
- @tracker_class = tracker
35
- @stages = stages.freeze
33
+ def initialize(conformance_validator: Xml::ConformanceValidator.new)
34
+ @conformance_validator = conformance_validator
36
35
  end
37
36
 
38
37
  def call(document:, profile:)
39
38
  context = compile(document:, profile:)
40
- report = context.tracker.report
41
- raise_conformance_error(profile, report) if report.invalid?
39
+ report = validate_context(context, profile)
40
+ raise_invalid_document(profile, report) if report.invalid?
42
41
 
43
- @schema_validator.call(document: context.xml, profile:)
44
42
  context.to_xml
45
43
  end
46
44
 
47
45
  def validate(document:, profile:)
48
- compile(document:, profile:).tracker.report
46
+ validate_context(compile(document:, profile:), profile)
49
47
  end
50
48
 
51
49
  private
52
50
 
53
51
  def compile(document:, profile:)
54
- tracker = @tracker_class.new(profile:)
52
+ tracker = Tracker.new(profile:)
55
53
  tracker.check_document(document, path: Terms.fetch('BT-24').xpath)
56
54
  context = Context.new(document:, profile:, tracker:)
57
- @stages.each { |stage| stage.new(context).call }
55
+ STAGES.each { |stage| stage.new(context).call }
58
56
  context
59
57
  end
60
58
 
61
- def raise_conformance_error(profile, report)
62
- raise ConformanceError.new(
59
+ def validate_context(context, profile)
60
+ report = context.tracker.report
61
+ return report if report.invalid?
62
+
63
+ issues = @conformance_validator.call(document: context.xml, profile:)
64
+ report.with(issues: report.issues + issues)
65
+ rescue XsdValidationError => e
66
+ Xml::ReportBuilder.xsd(profile, e)
67
+ end
68
+
69
+ def raise_invalid_document(profile, report)
70
+ raise InvalidDocumentError.new(
63
71
  'Factur-X document does not conform to the selected profile',
64
72
  profile: profile.id, report:, issues: report.issues
65
73
  )
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../schematron_adapter'
4
+ require_relative 'schema_validator'
5
+
6
+ module Facturx
7
+ module Xml
8
+ class ConformanceValidator
9
+ def initialize(schema_validator: SchemaValidator.new, schematron_validator: SchematronAdapter)
10
+ @schema_validator = schema_validator
11
+ @schematron_validator = schematron_validator
12
+ end
13
+
14
+ def call(document:, profile:)
15
+ @schema_validator.call(document:, profile:)
16
+ @schematron_validator.call(document:, profile:)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -8,7 +8,7 @@ module Facturx
8
8
  class Parser
9
9
  def call(xml:)
10
10
  unless xml.is_a?(String)
11
- raise InvalidXmlError.new('XML must be provided as a byte String', input_class: xml.class.name)
11
+ raise InvalidSourceError.new('XML must be provided as a byte String', input_class: xml.class.name)
12
12
  end
13
13
 
14
14
  Nokogiri::XML::Document.parse(xml) { |config| config.strict.nonet }
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../validation'
4
+
5
+ module Facturx
6
+ module Xml
7
+ class ReportBuilder
8
+ class << self
9
+ def syntax(error)
10
+ from_error(error, layer: :syntax, code: :invalid_xml)
11
+ end
12
+
13
+ def profile(error)
14
+ guideline_urn = error.details[:guideline_urn]
15
+ code = guideline_urn.nil? || guideline_urn.empty? ? :missing_profile : :unknown_profile
16
+ from_error(error, layer: :profile, code:)
17
+ end
18
+
19
+ def xsd(profile, error)
20
+ from_error(error, profile:, layer: :xsd, code: :xsd_violation)
21
+ end
22
+
23
+ private
24
+
25
+ def from_error(error, layer:, code:, profile: nil)
26
+ diagnostics = error.details[:errors] || [{ message: error.message }]
27
+ issues = diagnostics.map { |diagnostic| issue(error, diagnostic, layer:, code:) }
28
+ Validation::Report.new(profile:, issues:)
29
+ end
30
+
31
+ def issue(error, diagnostic, layer:, code:)
32
+ Validation::Issue.new(
33
+ code:,
34
+ message: diagnostic.fetch(:message),
35
+ layer:,
36
+ line: diagnostic[:line],
37
+ column: diagnostic[:column],
38
+ details: error.details.except(:errors).merge(diagnostic.except(:message, :line, :column))
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Facturx
4
+ module Xml
5
+ class SchemaRegistry
6
+ SPECIFICATION_VERSION = '1.09.2'
7
+ ROOT = File.expand_path("../schema/#{SPECIFICATION_VERSION}", __dir__).freeze
8
+ PATHS = {
9
+ minimum: 'minimum/Factur-X_1.09.2_MINIMUM.xsd',
10
+ basic_wl: 'basic-wl/Factur-X_1.09.2_BASICWL.xsd',
11
+ basic: 'basic/Factur-X_1.09.2_BASIC.xsd',
12
+ en16931: 'en16931/Factur-X_1.09.2_EN16931.xsd',
13
+ extended: 'extended/Factur-X_1.09.2_EXTENDED.xsd'
14
+ }.transform_values { |path| File.join(ROOT, path).freeze }.freeze
15
+ private_constant :SPECIFICATION_VERSION, :ROOT, :PATHS
16
+
17
+ def fetch(profile)
18
+ PATHS.fetch(profile.id)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'nokogiri'
4
4
  require_relative '../error'
5
+ require_relative 'schema_registry'
5
6
 
6
7
  module Facturx
7
8
  module Xml
@@ -27,6 +28,10 @@ module Facturx
27
28
  end
28
29
  end
29
30
 
31
+ def initialize(registry: SchemaRegistry.new)
32
+ @registry = registry
33
+ end
34
+
30
35
  def call(document:, profile:)
31
36
  validation_errors = load_schema(profile).validate(document)
32
37
  raise_validation_error(profile, validation_errors) unless validation_errors.empty?
@@ -35,7 +40,7 @@ module Facturx
35
40
  private
36
41
 
37
42
  def load_schema(profile)
38
- self.class.send(:load_schema, File.expand_path(profile.xsd_path))
43
+ self.class.send(:load_schema, @registry.fetch(profile))
39
44
  rescue SystemCallError, Nokogiri::XML::SyntaxError => e
40
45
  raise_schema_load_error(profile, e)
41
46
  end
@@ -53,7 +58,7 @@ module Facturx
53
58
  end
54
59
 
55
60
  def raise_schema_load_error(profile, error)
56
- raise XsdValidationError.new(
61
+ raise SchemaLoadError.new(
57
62
  "Unable to load the Factur-X #{profile.id} schema",
58
63
  profile: profile.id,
59
64
  errors: [{ message: error.message }.freeze].freeze
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../validation'
4
+ require_relative 'parser'
5
+ require_relative 'profile_detector'
6
+ require_relative 'report_builder'
7
+ require_relative 'conformance_validator'
8
+
9
+ module Facturx
10
+ module Xml
11
+ class Validator
12
+ def initialize(
13
+ parser: Parser.new,
14
+ profile_detector: ProfileDetector.new,
15
+ conformance_validator: ConformanceValidator.new
16
+ )
17
+ @parser = parser
18
+ @profile_detector = profile_detector
19
+ @conformance_validator = conformance_validator
20
+ end
21
+
22
+ def call(xml:)
23
+ document = @parser.call(xml:)
24
+ profile = @profile_detector.call(document:)
25
+ issues = @conformance_validator.call(document:, profile:)
26
+ Validation::Report.new(profile:, issues:)
27
+ rescue UnknownProfileError => e
28
+ ReportBuilder.profile(e)
29
+ rescue XsdValidationError => e
30
+ ReportBuilder.xsd(profile, e)
31
+ rescue InvalidXmlError => e
32
+ ReportBuilder.syntax(e)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -1,23 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'parser'
4
- require_relative 'profile_detector'
5
- require_relative 'schema_validator'
3
+ require_relative 'validator'
6
4
 
7
5
  module Facturx
8
6
  module Xml
9
7
  class Verifier
10
- def initialize(parser: Parser.new, profile_detector: ProfileDetector.new, schema_validator: SchemaValidator.new)
11
- @parser = parser
12
- @profile_detector = profile_detector
13
- @schema_validator = schema_validator
8
+ ERROR_CLASSES = {
9
+ syntax: InvalidXmlError,
10
+ profile: UnknownProfileError,
11
+ xsd: XsdValidationError,
12
+ schematron: SchematronValidationError
13
+ }.freeze
14
+ private_constant :ERROR_CLASSES
15
+
16
+ def initialize(validator: Validator.new)
17
+ @validator = validator
14
18
  end
15
19
 
16
20
  def call(xml:)
17
- document = @parser.call(xml: xml)
18
- profile = @profile_detector.call(document: document)
19
- @schema_validator.call(document: document, profile: profile)
20
- profile
21
+ report = @validator.call(xml:)
22
+ return report.profile if report.valid?
23
+
24
+ issue = report.issues.find { |candidate| candidate.severity == :error }
25
+ error_class = ERROR_CLASSES.fetch(issue.layer)
26
+ raise error_class.new(issue.message, report:, issues: report.issues)
21
27
  end
22
28
  end
23
29
  end
data/lib/facturx.rb CHANGED
@@ -16,12 +16,17 @@ require 'facturx/diagnostic'
16
16
  require 'facturx/reading'
17
17
  require 'facturx/coerce'
18
18
  require 'facturx/format'
19
- require 'facturx/conformance'
19
+ require 'facturx/validation'
20
+ require 'facturx/schematron_adapter'
21
+ require 'facturx/subprocess'
22
+ require 'facturx/xml/conformance_validator'
23
+ require 'facturx/xml/validator'
20
24
  require 'facturx/xml/verifier'
21
25
  require 'facturx/pdf/document'
22
26
  require 'facturx/pdf/inspector'
23
27
  require 'facturx/pdf/extractor'
24
28
  require 'facturx/pdf/verifier'
29
+ require 'facturx/pdf/composer'
25
30
  require 'facturx/composers/ghostscript'
26
31
  require 'facturx/attach'
27
32
  require 'facturx/reader'
@@ -29,12 +34,16 @@ require 'facturx/writer'
29
34
  require 'facturx/generate'
30
35
 
31
36
  module Facturx
32
- DEFAULT_ATTACHER = Attach.new
37
+ DEFAULT_XML_VALIDATOR = Xml::Validator.new
38
+ DEFAULT_PDF_COMPOSER = Pdf::Composer.new
39
+ DEFAULT_ATTACHER = Attach.new(pdf_composer: DEFAULT_PDF_COMPOSER)
33
40
  DEFAULT_READER = Reader.new
34
41
  DEFAULT_PROFILE_RESOLVER = ProfileResolver.new
35
42
  DEFAULT_WRITER = Writer.new
36
- DEFAULT_GENERATOR = Generate.new(writer: DEFAULT_WRITER, attacher: DEFAULT_ATTACHER,
43
+ DEFAULT_GENERATOR = Generate.new(writer: DEFAULT_WRITER, pdf_composer: DEFAULT_PDF_COMPOSER,
37
44
  profile_resolver: DEFAULT_PROFILE_RESOLVER)
45
+ private_constant :DEFAULT_XML_VALIDATOR
46
+ private_constant :DEFAULT_PDF_COMPOSER
38
47
  private_constant :DEFAULT_ATTACHER
39
48
  private_constant :DEFAULT_READER
40
49
  private_constant :DEFAULT_PROFILE_RESOLVER
@@ -42,9 +51,8 @@ module Facturx
42
51
  private_constant :DEFAULT_GENERATOR
43
52
 
44
53
  class << self
45
- def verify_xml(xml:)
46
- Xml::Verifier.new.call(xml:)
47
- true
54
+ def validate_xml(xml:)
55
+ DEFAULT_XML_VALIDATOR.call(xml:)
48
56
  end
49
57
 
50
58
  def attach(pdf:, xml:)
@@ -73,4 +81,8 @@ module Facturx
73
81
  DEFAULT_GENERATOR.call(pdf:, document:, profile:)
74
82
  end
75
83
  end
84
+
85
+ private_constant :Attach, :Builders, :Coerce, :CoercionError, :Composers, :Format, :FormattingError,
86
+ :Generate, :Group, :Model, :Pdf, :ProfileResolver, :Reader, :SourceReader, :Term,
87
+ :SchematronAdapter, :Subprocess, :TermDeclarations, :Terms, :Writer, :Xml
76
88
  end