pubid 2.0.6 → 2.0.7

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,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Nist
5
+ # The pubid-scheme URN of NIST (issue #656; docs/URN-SCHEMES.adoc).
6
+ #
7
+ # NIST SP 800-53r5
8
+ # urn:pubid:nist:sp:800-53:pub-NIST:series-SP:subseries-800:edtype-r:ed-5
9
+ #
10
+ # The core is the document number. +publisher+ and +series+ are atoms,
11
+ # because a few series print no number ("NIST OWMWP 06-13-2018" keeps
12
+ # its date there) and the series spelling varies inside one type
13
+ # ("CIRC", "NBS.CIRC", "LCIRC").
14
+ #
15
+ # +update+ and +update_component+ serialize every field of their
16
+ # component, so each field is its own atom. Reducing them to one
17
+ # identifying value changes #to_hash and needs a relaton-data-nist
18
+ # re-crawl; that is a separate decision (docs/URN-SCHEMES.adoc).
19
+ #
20
+ # Facets: +parsed_format+ (the input was an MR string), the printed
21
+ # prefix of a revision (" Rev. " against "r") and the bracketed
22
+ # +translation+ ("(spa)"), whose code is the +lang+ atom.
23
+ PubidUrnSchema = Pubid::PubidUrn.define("nist") do
24
+ atom "publisher", "pub"
25
+ atom "series", "series"
26
+ atom "subseries", "subseries"
27
+ atom "part.type", "parttype"
28
+ atom "part.value", "part"
29
+ atom "section", "section"
30
+ atom "volume", "vol"
31
+ atom "edition.type", "edtype"
32
+ atom "edition.id", "ed"
33
+ atom "edition.additional_text", "edtext"
34
+ atom "edition_year", "edyear"
35
+ atom "supplement.number", "sup"
36
+ atom "supplement.suffix", "supsuffix"
37
+ atom "supplement.year", "supyear"
38
+ atom "supplement.month", "supmonth"
39
+ atom "supplement.year_end", "supyearend"
40
+ atom "supplement_date_range_start", "suprangestart"
41
+ atom "supplement_date_range_end", "suprangeend"
42
+ atom "supplement.has_revision", "suprev", kind: :bool
43
+ atom "implicit_supplement", "implicitsup", kind: :bool
44
+ atom "update.prefix", "updprefix"
45
+ atom "update.number", "upd"
46
+ atom "update.year", "updyear"
47
+ atom "update.month", "updmonth"
48
+ atom "update_component.prefix", "ucprefix"
49
+ atom "update_component.number", "uc"
50
+ atom "update_component.year", "ucyear"
51
+ atom "update_component.month", "ucmonth"
52
+ atom "revision_year", "revyear"
53
+ atom "revision_month", "revmonth"
54
+ atom "revised_date", "reviseddate"
55
+ atom "update_number", "updnumber"
56
+ atom "update_year", "updnumberyear"
57
+ atom "stage.type", "stagetype"
58
+ atom "stage.id", "stage"
59
+ atom "draft_number", "draftnumber"
60
+ atom "version_component.value", "v"
61
+ atom "version", "version"
62
+ atom "year", "year", kind: :int
63
+ atom "month", "month", kind: :int
64
+ atom "date_year", "dateyear"
65
+ atom "date_month", "datemonth"
66
+ atom "date_day", "dateday"
67
+ atom "dated_seq", "datedseq"
68
+ atom "prefix", "prefix"
69
+ atom "range_notation", "range"
70
+ atom "translation_component.code", "lang"
71
+ atom "draft", "draft"
72
+ atom "index", "index"
73
+ atom "insert", "insert"
74
+ atom "errata", "errata"
75
+ atom "appendix", "appendix"
76
+ atom "addendum", "addendum"
77
+ atom "addendum_number", "addendumnumber"
78
+
79
+ facet "publisher_was_parsed", "pubparsed", kind: :bool
80
+ facet "parsed_format", "format"
81
+ facet "edition.original_prefix", "edprefix"
82
+ facet "translation", "translationsrc"
83
+
84
+ type "pubid:nist:identifier", "id"
85
+ type "pubid:nist:circular", "circ"
86
+ type "pubid:nist:circular-supplement", "circsup"
87
+ type "pubid:nist:commercial-standard", "cs"
88
+ type "pubid:nist:commercial-standard-emergency", "cse"
89
+ type "pubid:nist:commercial-standards-monthly", "csm"
90
+ type "pubid:nist:crpl-report", "crpl"
91
+ type "pubid:nist:dated-document", "dd"
92
+ type "pubid:nist:federal-information-processing-standards", "fips"
93
+ type "pubid:nist:grant-contractor-report", "gcr"
94
+ type "pubid:nist:handbook", "hb"
95
+ type "pubid:nist:interagency-report", "ir"
96
+ type "pubid:nist:letter-circular", "lc"
97
+ type "pubid:nist:miscellaneous-publication", "mp"
98
+ type "pubid:nist:monograph", "mono"
99
+ type "pubid:nist:ncstar", "ncstar"
100
+ type "pubid:nist:nsrds", "nsrds"
101
+ type "pubid:nist:owmwp", "owmwp"
102
+ type "pubid:nist:report", "rpt"
103
+ type "pubid:nist:special-publication", "sp"
104
+ type "pubid:nist:technical-note", "tn"
105
+ type "pubid:nist:supplement-identifier", "sup"
106
+ end
107
+ end
108
+ end
data/lib/pubid/nist.rb CHANGED
@@ -21,6 +21,7 @@ module Pubid
21
21
  autoload :Caster, "#{__dir__}/nist/caster"
22
22
  autoload :CircularSupplementBuilder, "#{__dir__}/nist/circular_supplement_builder"
23
23
  autoload :Components, "#{__dir__}/nist/components"
24
+ autoload :PubidUrnSchema, "#{__dir__}/nist/pubid_urn_schema"
24
25
  autoload :Configuration, "#{__dir__}/nist/configuration"
25
26
  autoload :Identifier, "#{__dir__}/nist/identifiers/base"
26
27
  autoload :Identifiers, "#{__dir__}/nist/identifiers"
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module PubidUrn
5
+ # Reads the body of a pubid-scheme URN back into an identifier hash.
6
+ # The inverse of Encoder; strict about structure. Canonical form (atom
7
+ # order, escapes, trailing "~") is checked by PubidUrn.parse, which
8
+ # re-encodes the result and compares.
9
+ class Decoder
10
+ FACET = /\A(\d+)\.([a-z0-9]+)-(.*)\z/
11
+
12
+ def initialize(body)
13
+ @body = body
14
+ end
15
+
16
+ # @return [Hash] the identifier hash (+_type+ and +base+ included)
17
+ def to_identifier_hash
18
+ flavor, rest = @body.split(":", 2)
19
+ fail!("no type segment") if rest.nil? || rest.empty?
20
+ decode_identifier(rest, flavor)
21
+ end
22
+
23
+ private
24
+
25
+ def decode_identifier(body, flavor)
26
+ tokens = PubidUrn.split_top(body, ":")
27
+ nodes = []
28
+ schemas = []
29
+ i = 0
30
+ while i < tokens.size
31
+ token = tokens[i]
32
+ if token == "f"
33
+ decode_facets(tokens.drop(i + 1), nodes, schemas)
34
+ break
35
+ end
36
+
37
+ schema = node_schema(token, flavor)
38
+ node = { "_type" => schema.type }
39
+ i = decode_core(tokens, i + 1, node, schema)
40
+ i = decode_atoms(tokens, i, node, schema)
41
+ nodes << node
42
+ schemas << schema
43
+ end
44
+ fail!("no identifier") if nodes.empty?
45
+
46
+ nodes.each_cons(2) { |base, node| node["base"] = base }
47
+ nodes.last
48
+ end
49
+
50
+ def node_schema(token, flavor)
51
+ if token == "type"
52
+ fail!("type-metadata URN names a type, not a document")
53
+ end
54
+
55
+ schema = if PubidUrn.structural?(token)
56
+ owner, name = PubidUrn.split_token(token, flavor)
57
+ PubidUrn.flavor_schema(owner)&.for_token(name)
58
+ end
59
+ fail!("unknown type or segment #{token.inspect}") unless schema
60
+
61
+ schema
62
+ end
63
+
64
+ def decode_core(tokens, index, node, schema)
65
+ schema.core.each do |parts|
66
+ break if index >= tokens.size
67
+
68
+ token = tokens[index]
69
+ break if PubidUrn.structural?(token)
70
+
71
+ Paths.put(node, parts, Escape.decode(token)) unless token == NULL
72
+ index += 1
73
+ end
74
+ index
75
+ end
76
+
77
+ def decode_atoms(tokens, index, node, schema)
78
+ last = -1
79
+ while index < tokens.size &&
80
+ (position = schema.atom_position(tokens[index]))
81
+ if position <= last
82
+ fail!("atom #{tokens[index].inspect} is out of order or repeated")
83
+ end
84
+
85
+ last = position
86
+ atom = schema.atoms[position]
87
+ raw = tokens[index].split("-", 2).last
88
+ Paths.put(node, atom.parts, atom_value(raw, atom))
89
+ index += 1
90
+ end
91
+ index
92
+ end
93
+
94
+ def atom_value(raw, atom)
95
+ case atom.kind
96
+ when :int then integer(raw)
97
+ when :id then nested(raw)
98
+ when :ids then PubidUrn.split_top(raw, ",").map { |item| nested(item) }
99
+ else slot_value(raw, atom)
100
+ end
101
+ end
102
+
103
+ # The value of a :string, :bool or list atom, or of a facet.
104
+ def slot_value(raw, slot)
105
+ return scalar(raw, slot) unless slot.list?
106
+
107
+ PubidUrn.split_top(raw, ",", allow_empty: true).map do |item|
108
+ item == NULL ? nil : scalar(item, slot)
109
+ end
110
+ end
111
+
112
+ def scalar(raw, slot)
113
+ slot.element == :bool ? boolean(raw) : Escape.decode(raw)
114
+ end
115
+
116
+ def boolean(raw)
117
+ return true if raw == "true"
118
+ return false if raw == "false"
119
+
120
+ fail!("#{raw.inspect} is not a boolean")
121
+ end
122
+
123
+ def integer(raw)
124
+ unless /\A-?[0-9]+\z/.match?(raw)
125
+ fail!("#{raw.inspect} is not an integer")
126
+ end
127
+
128
+ Integer(raw, 10)
129
+ end
130
+
131
+ def nested(raw)
132
+ unless raw.start_with?("(") && raw.end_with?(")")
133
+ fail!("nested identifier #{raw.inspect} needs brackets")
134
+ end
135
+
136
+ Decoder.new(raw[1..-2]).to_identifier_hash
137
+ end
138
+
139
+ def decode_facets(tokens, nodes, schemas)
140
+ fail!("empty facet group") if tokens.empty?
141
+
142
+ tokens.each do |token|
143
+ match = FACET.match(token) || fail!("bad facet #{token.inspect}")
144
+ index = Integer(match[1], 10)
145
+ schema = schemas[index] || fail!("facet #{token.inspect} has no node")
146
+ facet = schema.facet(match[2]) || fail!("bad facet #{token.inspect}")
147
+ Paths.put(nodes[index], facet.parts, slot_value(match[3], facet))
148
+ end
149
+ end
150
+
151
+ def fail!(message)
152
+ raise Errors::UrnParseError, "pubid URN #{@body.inspect}: #{message}"
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module PubidUrn
5
+ # Writes the pubid-scheme URN of an identifier hash.
6
+ #
7
+ # urn:pubid:<flavor>:<node>[:<node>…][:f:<facet>…]
8
+ # <node> = <type>[:<core>…][:<prefix>-<value>…]
9
+ #
10
+ # The nodes go root first, along +base+: "ISO 9001:2015/Amd 1:2016" is
11
+ # "is:9001:year-2015:amd:1:year-2016", so every supplement of a document
12
+ # shares its slug prefix. A core value is positional; an absent one in
13
+ # the middle is "~", and absent ones at the end are dropped. An optional
14
+ # value is a prefixed atom, so it leaves no segment when it is absent.
15
+ class Encoder
16
+ def initialize(hash)
17
+ @hash = hash
18
+ end
19
+
20
+ # @return [String] the body after "urn:pubid:"
21
+ def body
22
+ flavor = PubidUrn.flavor_segment(@hash["_type"])
23
+ "#{flavor}:#{encode_identifier(@hash, flavor)}"
24
+ end
25
+
26
+ private
27
+
28
+ def encode_identifier(hash, flavor)
29
+ segments = []
30
+ facets = []
31
+ chain(hash).each_with_index do |node, index|
32
+ schema = PubidUrn.type_schema!(node["_type"],
33
+ error: Errors::InvalidInputError)
34
+ node = deep_dup(node)
35
+ node.delete("_type")
36
+ segments << token(schema, flavor)
37
+ segments.concat(core_segments(node, schema))
38
+ segments.concat(atom_segments(node, schema))
39
+ facets.concat(facet_segments(node, schema, index))
40
+ check_placed!(node, schema)
41
+ end
42
+ segments.push("f", *facets) unless facets.empty?
43
+ segments.join(":")
44
+ end
45
+
46
+ # The nodes from the root document to +hash+ itself.
47
+ def chain(hash)
48
+ nodes = []
49
+ node = hash
50
+ while node
51
+ unless node.is_a?(Hash) && node["_type"].is_a?(String)
52
+ raise Errors::InvalidInputError,
53
+ "a pubid URN base must be an identifier hash, " \
54
+ "got #{node.inspect}"
55
+ end
56
+
57
+ nodes.unshift(node.except("base"))
58
+ node = node["base"]
59
+ end
60
+ nodes
61
+ end
62
+
63
+ def token(schema, flavor)
64
+ return schema.token if schema.flavor == flavor
65
+
66
+ "#{schema.flavor}.#{schema.token}"
67
+ end
68
+
69
+ def core_segments(node, schema)
70
+ values = schema.core.map { |parts| Paths.take(node, parts) }
71
+ values.pop while !values.empty? && values.last.nil?
72
+ values.map do |value|
73
+ value.nil? ? NULL : core_value(value, schema)
74
+ end
75
+ end
76
+
77
+ # A core value that has the shape of structure (PubidUrn::STRUCTURE)
78
+ # gets its first character escaped.
79
+ def core_value(value, schema)
80
+ unless value.is_a?(String) && !value.empty?
81
+ raise Errors::InvalidInputError,
82
+ "core value of #{schema.type} must be a non-empty String, " \
83
+ "got #{value.inspect}"
84
+ end
85
+
86
+ encoded = Escape.encode(value)
87
+ return encoded unless PubidUrn.structural?(encoded)
88
+
89
+ Escape.force(value[0]) + Escape.encode(value[1..])
90
+ end
91
+
92
+ def atom_segments(node, schema)
93
+ schema.atoms.filter_map do |atom|
94
+ value = Paths.take(node, atom.parts)
95
+ next if value.nil?
96
+
97
+ "#{atom.prefix}-#{atom_value(value, atom, schema)}"
98
+ end
99
+ end
100
+
101
+ def atom_value(value, atom, schema)
102
+ case atom.kind
103
+ when :int then typed(value, Integer, atom, schema).to_s
104
+ when :id then nested(typed(value, Hash, atom, schema))
105
+ when :ids then nested_list(typed(value, Array, atom, schema))
106
+ else slot_value(value, atom, schema)
107
+ end
108
+ end
109
+
110
+ # The value of a :string, :bool or list atom, or of a facet.
111
+ def slot_value(value, slot, schema)
112
+ return scalar(value, slot, schema) unless slot.list?
113
+
114
+ typed(value, Array, slot, schema).map do |item|
115
+ item.nil? ? NULL : scalar(item, slot, schema)
116
+ end.join(",")
117
+ end
118
+
119
+ def scalar(value, slot, schema)
120
+ if slot.element == :bool
121
+ typed(value, [true, false], slot, schema).to_s
122
+ else
123
+ Escape.encode(typed(value, String, slot, schema))
124
+ end
125
+ end
126
+
127
+ def nested(hash)
128
+ "(#{Encoder.new(hash).body})"
129
+ end
130
+
131
+ def nested_list(hashes)
132
+ hashes.map { |hash| nested(hash) }.join(",")
133
+ end
134
+
135
+ def facet_segments(node, schema, index)
136
+ schema.facets.filter_map do |facet|
137
+ value = Paths.take(node, facet.parts)
138
+ next if value.nil?
139
+
140
+ "#{index}.#{facet.name}-#{slot_value(value, facet, schema)}"
141
+ end
142
+ end
143
+
144
+ def typed(value, expected, slot, schema)
145
+ ok = if expected.is_a?(Array)
146
+ expected.include?(value)
147
+ else
148
+ value.is_a?(expected)
149
+ end
150
+ return value if ok
151
+
152
+ raise Errors::InvalidInputError,
153
+ "#{schema.type} #{slot.path}: #{value.inspect} " \
154
+ "is not #{slot.kind}"
155
+ end
156
+
157
+ def check_placed!(node, schema)
158
+ left = node.reject { |_, value| Paths.blank?(value) }
159
+ return if left.empty?
160
+
161
+ raise Errors::InvalidInputError,
162
+ "the pubid URN schema of #{schema.type} " \
163
+ "does not place #{left.inspect}"
164
+ end
165
+
166
+ def deep_dup(value)
167
+ case value
168
+ when Hash then value.to_h { |k, v| [k, deep_dup(v)] }
169
+ when Array then value.map { |v| deep_dup(v) }
170
+ else value
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module PubidUrn
5
+ # The value escape of the pubid-scheme URN.
6
+ #
7
+ # The URN is also an HTTP path slug (relaton/relaton#441), so it cannot
8
+ # use "%": servers and proxies decode "%2F" before they route, or they
9
+ # refuse it. "~XX" (uppercase hex of one UTF-8 byte) has no meaning in a
10
+ # path, and "~" is an RFC 3986 unreserved character. Every byte outside
11
+ # SAFE is escaped, so ":" "," "(" ")" "~" never occur raw in a value and
12
+ # stay free for the structure of the URN.
13
+ module Escape
14
+ SAFE = /[A-Za-z0-9._-]/
15
+ RAW_TOKEN = /\A(?:[A-Za-z0-9._-]|~[0-9A-F]{2})*\z/
16
+
17
+ module_function
18
+
19
+ # @param value [String]
20
+ # @return [String]
21
+ def encode(value)
22
+ value.to_s.each_char.map { |char| encode_char(char) }.join
23
+ end
24
+
25
+ # Escape every character of +char+, safe or not. The codec uses it on
26
+ # the first character of a core value that would otherwise read as
27
+ # structure ("amd" -> "~61md").
28
+ #
29
+ # @param char [String] one character
30
+ # @return [String]
31
+ def force(char)
32
+ char.b.each_byte.map { |byte| format("~%02X", byte) }.join
33
+ end
34
+
35
+ # @param token [String]
36
+ # @return [String]
37
+ # @raise [Pubid::Errors::UrnParseError] for a raw character outside the
38
+ # safe set, or a malformed or lower-case escape
39
+ def decode(token)
40
+ unless RAW_TOKEN.match?(token)
41
+ raise Pubid::Errors::UrnParseError,
42
+ "bad character or escape in pubid URN value #{token.inspect}"
43
+ end
44
+
45
+ bytes = token.b.gsub(/~([0-9A-F]{2})/n) { Regexp.last_match(1).hex.chr }
46
+ decoded = bytes.force_encoding(Encoding::UTF_8)
47
+ unless decoded.valid_encoding?
48
+ raise Pubid::Errors::UrnParseError,
49
+ "escape in pubid URN value #{token.inspect} is not UTF-8"
50
+ end
51
+
52
+ decoded
53
+ end
54
+
55
+ def encode_char(char)
56
+ SAFE.match?(char) ? char : force(char)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module PubidUrn
5
+ # Reads and writes a dotted key path ("edition.number",
6
+ # "languages.*.code") in an identifier hash.
7
+ #
8
+ # +take+ removes what it reads, so after every core path, atom and facet
9
+ # has been taken, anything left in the node is a key that the schema
10
+ # does not place — the encoder refuses it rather than drop it silently.
11
+ #
12
+ # A degenerate component may serialize as a bare scalar ("edition" =>
13
+ # "2" rather than {"number" => "2"}, see FLAT_SCALAR_FIELDS). Where a
14
+ # path expects a Hash and finds a scalar, the scalar IS the value of
15
+ # the path. +put+ always writes the nested form, which +from_hash+ reads
16
+ # as well, so the rebuilt identifier serializes alike.
17
+ module Paths
18
+ module_function
19
+
20
+ # @return [Object, nil] the value, or nil when the path is absent
21
+ def take(container, parts)
22
+ return nil unless container.is_a?(Hash) && container.key?(parts.first)
23
+
24
+ key, *rest = parts
25
+ return container.delete(key) if rest.empty?
26
+
27
+ value = container[key]
28
+ if rest.first == "*"
29
+ take_elements(container, key, value, rest.drop(1))
30
+ elsif value.is_a?(Hash)
31
+ take(value, rest)
32
+ elsif rest.size == 1
33
+ container.delete(key)
34
+ end
35
+ end
36
+
37
+ def take_elements(container, key, value, rest)
38
+ return nil unless value.is_a?(Array)
39
+ return container.delete(key) if rest.empty?
40
+
41
+ values = value.each_with_index.map do |element, i|
42
+ if element.is_a?(Hash)
43
+ take(element, rest)
44
+ elsif rest.size == 1 && !element.nil?
45
+ value[i] = {}
46
+ element
47
+ end
48
+ end
49
+ values.all?(&:nil?) ? nil : values
50
+ end
51
+
52
+ # Write +value+ (nil writes nothing).
53
+ def put(container, parts, value)
54
+ return if value.nil?
55
+
56
+ key, *rest = parts
57
+ if rest.empty?
58
+ container[key] = value
59
+ elsif rest.first == "*"
60
+ put_elements(container, key, value, rest.drop(1))
61
+ else
62
+ put(container[key] ||= {}, rest, value)
63
+ end
64
+ end
65
+
66
+ def put_elements(container, key, values, rest)
67
+ return container[key] = values if rest.empty?
68
+
69
+ elements = (container[key] ||= [])
70
+ values.each_with_index do |value, i|
71
+ next if value.nil?
72
+
73
+ put(elements[i] ||= {}, rest, value)
74
+ end
75
+ end
76
+
77
+ # True when nothing but empty containers is left.
78
+ def blank?(value)
79
+ case value
80
+ when Hash then value.each_value.all? { |v| blank?(v) }
81
+ when Array then value.all? { |v| blank?(v) }
82
+ else false
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end