facturx 1.0.0 → 1.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.
@@ -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
 
@@ -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,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].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.1.0'
5
5
  end
@@ -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,10 +1,10 @@
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'
@@ -12,6 +12,7 @@ require_relative 'writer/stages/trade_agreement'
12
12
  require_relative 'writer/stages/trade_delivery'
13
13
  require_relative 'writer/stages/trade_settlement'
14
14
  require_relative 'xml/schema_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
+ def initialize(schema_validator: Xml::SchemaValidator.new)
33
34
  @schema_validator = schema_validator
34
- @tracker_class = tracker
35
- @stages = stages.freeze
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
+ @schema_validator.call(document: context.xml, profile:)
64
+ report
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
  )
@@ -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,32 @@
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 'schema_validator'
8
+
9
+ module Facturx
10
+ module Xml
11
+ class Validator
12
+ def initialize(parser: Parser.new, profile_detector: ProfileDetector.new, schema_validator: SchemaValidator.new)
13
+ @parser = parser
14
+ @profile_detector = profile_detector
15
+ @schema_validator = schema_validator
16
+ end
17
+
18
+ def call(xml:)
19
+ document = @parser.call(xml:)
20
+ profile = @profile_detector.call(document:)
21
+ @schema_validator.call(document:, profile:)
22
+ Validation::Report.new(profile:)
23
+ rescue UnknownProfileError => e
24
+ ReportBuilder.profile(e)
25
+ rescue XsdValidationError => e
26
+ ReportBuilder.xsd(profile, e)
27
+ rescue InvalidXmlError => e
28
+ ReportBuilder.syntax(e)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,23 +1,28 @@
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
+ }.freeze
13
+ private_constant :ERROR_CLASSES
14
+
15
+ def initialize(validator: Validator.new)
16
+ @validator = validator
14
17
  end
15
18
 
16
19
  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
20
+ report = @validator.call(xml:)
21
+ return report.profile if report.valid?
22
+
23
+ issue = report.issues.first
24
+ error_class = ERROR_CLASSES.fetch(issue.layer)
25
+ raise error_class.new(issue.message, report:, issues: report.issues)
21
26
  end
22
27
  end
23
28
  end
data/lib/facturx.rb CHANGED
@@ -16,12 +16,14 @@ 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/xml/validator'
20
21
  require 'facturx/xml/verifier'
21
22
  require 'facturx/pdf/document'
22
23
  require 'facturx/pdf/inspector'
23
24
  require 'facturx/pdf/extractor'
24
25
  require 'facturx/pdf/verifier'
26
+ require 'facturx/pdf/composer'
25
27
  require 'facturx/composers/ghostscript'
26
28
  require 'facturx/attach'
27
29
  require 'facturx/reader'
@@ -29,12 +31,16 @@ require 'facturx/writer'
29
31
  require 'facturx/generate'
30
32
 
31
33
  module Facturx
32
- DEFAULT_ATTACHER = Attach.new
34
+ DEFAULT_XML_VALIDATOR = Xml::Validator.new
35
+ DEFAULT_PDF_COMPOSER = Pdf::Composer.new
36
+ DEFAULT_ATTACHER = Attach.new(pdf_composer: DEFAULT_PDF_COMPOSER)
33
37
  DEFAULT_READER = Reader.new
34
38
  DEFAULT_PROFILE_RESOLVER = ProfileResolver.new
35
39
  DEFAULT_WRITER = Writer.new
36
- DEFAULT_GENERATOR = Generate.new(writer: DEFAULT_WRITER, attacher: DEFAULT_ATTACHER,
40
+ DEFAULT_GENERATOR = Generate.new(writer: DEFAULT_WRITER, pdf_composer: DEFAULT_PDF_COMPOSER,
37
41
  profile_resolver: DEFAULT_PROFILE_RESOLVER)
42
+ private_constant :DEFAULT_XML_VALIDATOR
43
+ private_constant :DEFAULT_PDF_COMPOSER
38
44
  private_constant :DEFAULT_ATTACHER
39
45
  private_constant :DEFAULT_READER
40
46
  private_constant :DEFAULT_PROFILE_RESOLVER
@@ -42,9 +48,8 @@ module Facturx
42
48
  private_constant :DEFAULT_GENERATOR
43
49
 
44
50
  class << self
45
- def verify_xml(xml:)
46
- Xml::Verifier.new.call(xml:)
47
- true
51
+ def validate_xml(xml:)
52
+ DEFAULT_XML_VALIDATOR.call(xml:)
48
53
  end
49
54
 
50
55
  def attach(pdf:, xml:)
@@ -73,4 +78,8 @@ module Facturx
73
78
  DEFAULT_GENERATOR.call(pdf:, document:, profile:)
74
79
  end
75
80
  end
81
+
82
+ private_constant :Attach, :Builders, :Coerce, :CoercionError, :Composers, :Format, :FormattingError,
83
+ :Generate, :Group, :Model, :Pdf, :ProfileResolver, :Reader, :SourceReader, :Term,
84
+ :TermDeclarations, :Terms, :Writer, :Xml
76
85
  end