linzer 0.7.9.beta3 → 0.8.0.beta1

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.
data/lib/linzer/key.rb CHANGED
@@ -38,6 +38,8 @@ module Linzer
38
38
  @material = material
39
39
  @params = Hash(params).clone.freeze
40
40
  validate
41
+ @is_private = compute_private?
42
+ @is_public = compute_public?
41
43
  freeze
42
44
  end
43
45
 
@@ -84,14 +86,14 @@ module Linzer
84
86
  #
85
87
  # @return [Boolean] true if the key contains public key material
86
88
  def public?
87
- material.public?
89
+ @is_public
88
90
  end
89
91
 
90
92
  # Checks if this key can be used for signing.
91
93
  #
92
94
  # @return [Boolean] true if the key contains private key material
93
95
  def private?
94
- material.private?
96
+ @is_private
95
97
  end
96
98
 
97
99
  private
@@ -102,6 +104,22 @@ module Linzer
102
104
  !material.nil? or raise Error.new "Invalid key. No key material provided."
103
105
  end
104
106
 
107
+ # Computes whether the key contains private key material.
108
+ # Override in subclasses where the OpenSSL key object does not
109
+ # respond to +private?+ (e.g. Ed25519, RSA-PSS).
110
+ # @return [Boolean]
111
+ def compute_private?
112
+ material.respond_to?(:private?) ? material.private? : false
113
+ end
114
+
115
+ # Computes whether the key contains public key material.
116
+ # Override in subclasses where the OpenSSL key object does not
117
+ # respond to +public?+ (e.g. Ed25519, RSA-PSS).
118
+ # @return [Boolean]
119
+ def compute_public?
120
+ material.respond_to?(:public?) ? material.public? : false
121
+ end
122
+
105
123
  # Validates that a digest algorithm is configured.
106
124
  # @raise [Error] If no digest algorithm is set
107
125
  def validate_digest
@@ -10,8 +10,8 @@ module Linzer
10
10
  # implements field retrieval, header access, and signature attachment
11
11
  # for a specific HTTP message type.
12
12
  #
13
- # @abstract Subclass and implement {#header}, {#attach!}, {#derived},
14
- # and {#field} to create a new adapter.
13
+ # @abstract Subclass and implement {#header}, {#derived}, and {#field}
14
+ # to create a new adapter.
15
15
  #
16
16
  # @see Rack::Request Rack request adapter
17
17
  # @see Rack::Response Rack response adapter
@@ -70,7 +70,7 @@ module Linzer
70
70
  # @example With structured field parameter
71
71
  # adapter['"example-dict";key="a"'] # => "1"
72
72
  def [](field)
73
- field_id = field.is_a?(FieldId) ? field : parse_field_name(field)
73
+ field_id = (field.is_a?(FieldId) || field.is_a?(Field::FastIdentifier)) ? field : parse_field_name(field)
74
74
  return nil if field_id.nil? || field_id.item.nil?
75
75
  retrieve(field_id.item, field_id.derived? ? :derived : :field)
76
76
  end
@@ -84,13 +84,39 @@ module Linzer
84
84
  raise Linzer::Error, "Sub-classes are required to implement this method!"
85
85
  end
86
86
 
87
+ # Checks whether the request contains HTTP Message Signature headers.
88
+ #
89
+ # Returns true if either the "signature-input" or "signature" header
90
+ # is present.
91
+ #
92
+ # @return [Boolean] true if the request includes HTTP Message Signature headers
93
+ def has_signature?
94
+ !!header("signature-input") || !!header("signature")
95
+ end
96
+
87
97
  # Attaches a signature to the underlying HTTP message.
88
98
  #
89
- # @abstract Subclasses must implement this method.
90
99
  # @param signature [Signature] The signature to attach
91
100
  # @return [Object] The underlying HTTP message
92
101
  def attach!(signature)
93
- raise Linzer::Error, "Sub-classes are required to implement this method!"
102
+ signature_headers = signature.to_h
103
+
104
+ unless has_signature?
105
+ signature_headers.each { |h, v| set_header!(h, v) }
106
+ return @operation
107
+ end
108
+
109
+ signature_headers.each do |hdr, value|
110
+ merged = Starry.parse_dictionary(String(header(hdr)))
111
+ merged.merge!(Starry.parse_dictionary(value))
112
+ set_header!(hdr, Starry.serialize_dictionary(merged))
113
+ end
114
+
115
+ @operation
116
+ rescue Starry::ParseError => e
117
+ raise Error,
118
+ "Cannot attach signature, invalid signature header(s)!",
119
+ cause: e
94
120
  end
95
121
 
96
122
  private
@@ -164,10 +190,11 @@ module Linzer
164
190
  # @param method [Symbol] +:derived+ or +:field+
165
191
  # @return [String, Integer, nil] the component value
166
192
  def retrieve(name, method)
167
- if !name.parameters.empty?
168
- valid_params = validate_parameters(name, method)
169
- return nil if !valid_params
170
- end
193
+ # Fast path: no parameters means no special handling needed
194
+ return send(method, name) if name.parameters.empty?
195
+
196
+ valid_params = validate_parameters(name, method)
197
+ return nil if !valid_params
171
198
 
172
199
  has_req = name.parameters["req"]
173
200
  has_sf = name.parameters["sf"] || name.parameters.key?("key")
@@ -16,15 +16,6 @@ module Linzer
16
16
  # @see Generic::Response
17
17
  # @see https://github.com/lostisland/faraday faraday gem
18
18
  class Response < Generic::Response
19
- # Attaches a signature to the underlying response headers.
20
- #
21
- # @param signature [Linzer::Signature] the signature to attach
22
- # @return [::Faraday::Response] the underlying response object
23
- def attach!(signature)
24
- signature.to_h.each { |h, v| @operation.headers[h] = v }
25
- @operation
26
- end
27
-
28
19
  private
29
20
 
30
21
  # Resolves a derived component value from the response.
@@ -37,6 +28,17 @@ module Linzer
37
28
  when "@status" then @operation.status.to_i
38
29
  end
39
30
  end
31
+
32
+ # Sets a header on the underlying HTTP message.
33
+ #
34
+ # If a header with the given name already exists, its value is overwritten.
35
+ #
36
+ # @param header [String] the header name
37
+ # @param value [String] the header value
38
+ # @return [String] the value assigned to the header
39
+ def set_header!(header, value)
40
+ @operation.headers[header] = value
41
+ end
40
42
  end
41
43
  end
42
44
  end
@@ -40,14 +40,6 @@ module Linzer
40
40
  @operation[name]
41
41
  end
42
42
 
43
- # Attaches a signature to the request.
44
- # @param signature [Signature] The signature to attach
45
- # @return [Object] The underlying request object
46
- def attach!(signature)
47
- signature.to_h.each { |h, v| @operation[h] = v }
48
- @operation
49
- end
50
-
51
43
  private
52
44
 
53
45
  def derived(name)
@@ -68,6 +60,17 @@ module Linzer
68
60
  end
69
61
  end
70
62
 
63
+ # Sets a header on the underlying HTTP message.
64
+ #
65
+ # If a header with the given name already exists, its value is overwritten.
66
+ #
67
+ # @param header [String] the header name
68
+ # @param value [String] the header value
69
+ # @return [String] the value assigned to the header
70
+ def set_header!(header, value)
71
+ @operation[header] = value
72
+ end
73
+
71
74
  def query_param(uri_query, name)
72
75
  param_name = name.parameters["name"]
73
76
  return nil if !param_name
@@ -31,16 +31,19 @@ module Linzer
31
31
  @operation[name]
32
32
  end
33
33
 
34
- # Attaches a signature to the response.
35
- # @param signature [Signature] The signature to attach
36
- # @return [Object] The underlying response object
37
- def attach!(signature)
38
- signature.to_h.each { |h, v| @operation[h] = v }
39
- @operation
40
- end
41
-
42
34
  private
43
35
 
36
+ # Sets a header on the underlying HTTP message.
37
+ #
38
+ # If a header with the given name already exists, its value is overwritten.
39
+ #
40
+ # @param header [String] the header name
41
+ # @param value [String] the header value
42
+ # @return [String] the value assigned to the header
43
+ def set_header!(header, value)
44
+ @operation[header] = value
45
+ end
46
+
44
47
  def derived(name)
45
48
  raise Linzer::Error, "Sub-classes are required to implement this method!"
46
49
  end
@@ -20,14 +20,6 @@ module Linzer
20
20
  @operation.headers[name]
21
21
  end
22
22
 
23
- # Attaches a signature to the response.
24
- # @param signature [Signature] The signature to attach
25
- # @return [Object] The underlying response object
26
- def attach!(signature)
27
- signature.to_h.each { |h, v| @operation.headers[h] = v }
28
- @operation
29
- end
30
-
31
23
  private
32
24
 
33
25
  # Retrieves an HTTP field value from the request or response headers.
@@ -41,6 +33,17 @@ module Linzer
41
33
  value = @operation.headers[name.value.to_s]
42
34
  value.dup&.strip
43
35
  end
36
+
37
+ # Sets a header on the underlying HTTP message.
38
+ #
39
+ # If a header with the given name already exists, its value is overwritten.
40
+ #
41
+ # @param header [String] the header name
42
+ # @param value [String] the header value
43
+ # @return [String] the value assigned to the header
44
+ def set_header!(header, value)
45
+ @operation.headers[header] = value
46
+ end
44
47
  end
45
48
  end
46
49
  end
@@ -27,14 +27,17 @@ module Linzer
27
27
  @operation.get_header(rack_header_name(name))
28
28
  end
29
29
 
30
- # Attaches a signature to the request.
31
- # @param signature [Signature] The signature to attach
32
- # @return [::Rack::Request] The request with signature headers
33
- def attach!(signature)
34
- signature.to_h.each do |h, v|
35
- @operation.set_header(rack_header_name(h), v)
36
- end
37
- @operation
30
+ private
31
+
32
+ # Sets a header on the underlying HTTP message.
33
+ #
34
+ # If a header with the given name already exists, its value is overwritten.
35
+ #
36
+ # @param header [String] the header name
37
+ # @param value [String] the header value
38
+ # @return [String] the value assigned to the header
39
+ def set_header!(header, value)
40
+ @operation.set_header(rack_header_name(header), value)
38
41
  end
39
42
  end
40
43
  end
@@ -28,14 +28,17 @@ module Linzer
28
28
  @operation.get_header(name)
29
29
  end
30
30
 
31
- # Attaches a signature to the response.
32
- # @param signature [Signature] The signature to attach
33
- # @return [::Rack::Response] The response with signature headers
34
- def attach!(signature)
35
- signature.to_h.each do |h, v|
36
- @operation.set_header(h, v)
37
- end
38
- @operation
31
+ private
32
+
33
+ # Sets a header on the underlying HTTP message.
34
+ #
35
+ # If a header with the given name already exists, its value is overwritten.
36
+ #
37
+ # @param header [String] the header name
38
+ # @param value [String] the header value
39
+ # @return [String] the value assigned to the header
40
+ def set_header!(header, value)
41
+ @operation.set_header(header, value)
39
42
  end
40
43
  end
41
44
  end
@@ -3,9 +3,6 @@
3
3
  require_relative "adapter/abstract"
4
4
  require_relative "adapter/generic/request"
5
5
  require_relative "adapter/generic/response"
6
- require_relative "adapter/rack/common"
7
- require_relative "adapter/rack/request"
8
- require_relative "adapter/rack/response"
9
6
  require_relative "adapter/net_http/request"
10
7
  require_relative "adapter/net_http/response"
11
8
 
@@ -34,7 +34,7 @@ module Linzer
34
34
  # @raise [Error] If the component identifier is invalid
35
35
  def serialize
36
36
  raise Error, "Invalid component identifier: '#{field_name}'!" unless item
37
- Starry.serialize(@item)
37
+ @serialized || Starry.serialize(@item)
38
38
  end
39
39
  end
40
40
 
@@ -54,6 +54,29 @@ module Linzer
54
54
 
55
55
  Identifier.include Message::Field::IdentifierMethods
56
56
 
57
+ # Lightweight FieldId for simple components (no parameters).
58
+ # Bypasses Starry parsing entirely. Duck-types with Identifier
59
+ # for use in the adapter's [] method.
60
+ # @api private
61
+ class FastIdentifier
62
+ def initialize(serialized, item)
63
+ @field_name = serialized
64
+ @item = item
65
+ @serialized = serialized
66
+ freeze
67
+ end
68
+
69
+ attr_reader :field_name, :item
70
+
71
+ def derived?
72
+ @item.value.start_with?("@")
73
+ end
74
+
75
+ def serialize
76
+ @serialized
77
+ end
78
+ end
79
+
57
80
  class Identifier
58
81
  class << self
59
82
  # Serializes a single component identifier.
@@ -70,6 +93,35 @@ module Linzer
70
93
  components.map(&method(:serialize))
71
94
  end
72
95
 
96
+ # Serializes an array of component identifiers, returning both
97
+ # the serialized strings and the FieldId objects for reuse.
98
+ # @param components [Array<String>] Component names
99
+ # @return [Array(Array<String>, Array<Identifier>)] Serialized strings and FieldId objects
100
+ def serialize_components_with_field_ids(components)
101
+ serialized = Array.new(components.size)
102
+ field_ids = Array.new(components.size)
103
+
104
+ components.each_with_index do |c, i|
105
+ if c.include?(";") || c.include?('"')
106
+ # Complex component with parameters or already serialized:
107
+ # fall back to full Starry parsing
108
+ fid = new(field_name: c)
109
+ field_ids[i] = fid
110
+ serialized[i] = fid.serialize
111
+ else
112
+ # Simple component (e.g. "@method", "content-type"):
113
+ # build the Item and serialized string directly,
114
+ # bypassing Starry.parse_item + Starry.serialize
115
+ quoted = "\"#{c}\""
116
+ item = Starry::Item.new(c, {})
117
+ field_ids[i] = FastIdentifier.new(quoted, item)
118
+ serialized[i] = quoted
119
+ end
120
+ end
121
+
122
+ [serialized, field_ids]
123
+ end
124
+
73
125
  # Deserializes component identifiers back to names.
74
126
  # @param components [Array<String>] Serialized identifiers
75
127
  # @return [Array<String>] Component names
@@ -12,8 +12,6 @@ module Linzer
12
12
  module Wrapper
13
13
  # Default adapter mappings for built-in HTTP library support.
14
14
  @adapters = {
15
- Rack::Request => Linzer::Message::Adapter::Rack::Request,
16
- Rack::Response => Linzer::Message::Adapter::Rack::Response,
17
15
  Net::HTTPRequest => Linzer::Message::Adapter::NetHTTP::Request,
18
16
  Net::HTTPResponse => Linzer::Message::Adapter::NetHTTP::Response
19
17
  }
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rack integration for Linzer.
4
+ #
5
+ # Require this file to enable Rack support. It loads the Rack adapter
6
+ # classes and registers them so that {Rack::Request} and {Rack::Response}
7
+ # objects can be used directly with the Linzer signing and verification API.
8
+ #
9
+ # @example
10
+ # require "linzer/rack"
11
+ #
12
+ # use Rack::Auth::Signature,
13
+ # except: "/login",
14
+ # default: :my_key
15
+
16
+ require "rack"
17
+ require "linzer"
18
+ require "rack/auth/signature"
19
+ require "linzer/message/adapter/rack/common"
20
+ require "linzer/message/adapter/rack/request"
21
+ require "linzer/message/adapter/rack/response"
22
+
23
+ Linzer::Message.register_adapter(Rack::Request, Linzer::Message::Adapter::Rack::Request)
24
+ Linzer::Message.register_adapter(Rack::Response, Linzer::Message::Adapter::Rack::Response)
@@ -65,18 +65,18 @@ module Linzer
65
65
  )
66
66
  end
67
67
 
68
+ private
69
+
68
70
  # @return [Boolean] true if this key contains public key material
69
- def public?
71
+ def compute_public?
70
72
  has_pem_public?
71
73
  end
72
74
 
73
75
  # @return [Boolean] true if this key contains private key material
74
- def private?
76
+ def compute_private?
75
77
  has_pem_private?
76
78
  end
77
79
 
78
- private
79
-
80
80
  # Returns OpenSSL options for PSS signature operations.
81
81
  # @return [Hash] OpenSSL signature options
82
82
  def signature_options
@@ -26,12 +26,14 @@ module Linzer
26
26
  # @see https://www.rfc-editor.org/rfc/rfc9421.html#section-4 RFC 9421 Section 4
27
27
  class Signature
28
28
  # @api private
29
- # Use {.build} to create Signature instances.
30
- def initialize(metadata, value, label, parameters = {})
31
- @metadata = metadata.clone.freeze
32
- @value = value.clone.freeze
33
- @parameters = parameters.clone.freeze
34
- @label = label.clone.freeze
29
+ # Use {.build} or {.from_components} to create Signature instances.
30
+ def initialize(metadata, value, label, parameters = {}, parsed_items: nil, headers: nil)
31
+ @metadata = metadata.clone.freeze
32
+ @value = value.clone.freeze
33
+ @parameters = parameters.clone.freeze
34
+ @label = label.clone.freeze
35
+ @parsed_items = parsed_items&.freeze
36
+ @headers = headers&.freeze
35
37
  freeze
36
38
  end
37
39
 
@@ -74,6 +76,20 @@ module Linzer
74
76
  FieldId.deserialize_components(serialized_components)
75
77
  end
76
78
 
79
+ # Builds FieldId objects for each covered component.
80
+ #
81
+ # Uses {parsed_items} when available to create {FastIdentifier} objects
82
+ # that bypass Starry re-parsing. Falls back to constructing full
83
+ # {FieldId} objects from the serialized strings.
84
+ #
85
+ # Returns a fresh array each time because some adapter methods may
86
+ # mutate item parameters during field lookup (e.g., deleting "req").
87
+ #
88
+ # @return [Array<FastIdentifier, FieldId>] FieldId objects for each component
89
+ def field_ids
90
+ build_field_ids
91
+ end
92
+
77
93
  # Returns the signature creation timestamp.
78
94
  #
79
95
  # @return [Integer, nil] Unix timestamp when the signature was created,
@@ -132,20 +148,60 @@ module Linzer
132
148
  # @example Attaching to a Net::HTTP request
133
149
  # signature.to_h.each { |name, value| request[name] = value }
134
150
  def to_h
151
+ return @headers if @headers
152
+
153
+ items = @parsed_items || serialized_components.map { |c| Starry.parse_item(c) }
135
154
  {
136
155
  "signature" => Starry.serialize({label => value}),
137
156
  "signature-input" => Starry.serialize({
138
- label => Starry::InnerList.new(
139
- serialized_components.map { |c| Starry.parse_item(c) },
140
- parameters
141
- )
157
+ label => Starry::InnerList.new(items, parameters)
142
158
  })
143
159
  }
144
160
  end
145
161
 
162
+ private
163
+
164
+ def build_field_ids
165
+ if @parsed_items && @parsed_items.size == @metadata.size
166
+ @metadata.each_with_index.map do |serialized, i|
167
+ item = @parsed_items[i]
168
+ # Clone items that have parameters since the adapter's retrieve
169
+ # method may mutate parameters (e.g., deleting "req").
170
+ unless item.parameters.empty?
171
+ item = Starry::Item.new(item.value, item.parameters.dup)
172
+ end
173
+ Message::Field::FastIdentifier.new(serialized, item)
174
+ end
175
+ else
176
+ @metadata.map { |c| FieldId.new(field_name: c) }
177
+ end
178
+ end
179
+
146
180
  class << self
147
181
  private :new
148
182
 
183
+ # Creates a Signature directly from its constituent parts.
184
+ #
185
+ # This avoids the serialize-then-parse round-trip when the caller
186
+ # (e.g. {Signer.sign}) already has all the data.
187
+ #
188
+ # @api private
189
+ # @param components [Array<String>] Serialized component identifiers
190
+ # @param raw_signature [String] The raw signature bytes
191
+ # @param label [String] The signature label
192
+ # @param parameters [Hash] Signature parameters (symbol keys)
193
+ # @param parsed_items [Array<Starry::Item>] Pre-parsed component items
194
+ # @param headers [Hash] Pre-serialized header strings
195
+ # @return [Signature] The constructed signature
196
+ def from_components(components:, raw_signature:, label:, parameters:, parsed_items:, headers:)
197
+ # Signature stores parameters with string keys (as produced by Starry
198
+ # parsing). Convert symbol keys from Signer to match.
199
+ string_params = {}
200
+ parameters.each { |k, v| string_params[k.to_s] = v }
201
+ new(components, raw_signature, label, string_params,
202
+ parsed_items: parsed_items, headers: headers)
203
+ end
204
+
149
205
  # Builds a Signature from HTTP headers.
150
206
  #
151
207
  # Parses the `signature` and `signature-input` headers according to
@@ -184,19 +240,15 @@ module Linzer
184
240
  reject_multiple_signatures if input.size > 1 && options[:label].nil?
185
241
  label = options[:label] || input.keys.first
186
242
 
187
- signature = parse_structured_field(headers, "signature")
188
- fail_with_signature_not_found label unless signature.key?(label)
189
-
190
- raw_signature =
191
- signature[label].value
192
- .force_encoding(Encoding::ASCII_8BIT)
243
+ raw_signature = extract_raw_signature(headers["signature"], label)
193
244
 
194
245
  fail_due_invalid_components unless input[label].value.respond_to?(:each)
195
246
 
196
- components = input[label].value.map { |c| Starry.serialize_item(c) }
247
+ parsed_items = input[label].value
248
+ components = serialize_parsed_items(parsed_items)
197
249
  parameters = input[label].parameters
198
250
 
199
- new(components, raw_signature, label, parameters)
251
+ new(components, raw_signature, label, parameters, parsed_items: parsed_items)
200
252
  end
201
253
 
202
254
  private
@@ -223,6 +275,79 @@ module Linzer
223
275
  raise Error.new "Unexpected value for covered components."
224
276
  end
225
277
 
278
+ # Extracts the raw signature bytes for a given label from the
279
+ # signature header without a full Starry dictionary parse.
280
+ #
281
+ # The signature header format is: label=:base64data:
282
+ # For multiple signatures: label1=:b64:, label2=:b64:
283
+ #
284
+ # Falls back to full Starry parsing for non-trivial cases.
285
+ #
286
+ # @param header_value [String] the raw signature header
287
+ # @param label [String] the signature label to extract
288
+ # @return [String] the decoded signature bytes (ASCII-8BIT)
289
+ # @raise [Error] if the label is not found or decoding fails
290
+ def extract_raw_signature(header_value, label)
291
+ value = header_value.is_a?(String) ? header_value : header_value.to_s
292
+ prefix = "#{label}=:"
293
+
294
+ # Fast path: single signature (most common case)
295
+ if value.start_with?(prefix)
296
+ end_idx = value.index(":", prefix.length)
297
+ if end_idx
298
+ encoded = value[prefix.length...end_idx]
299
+ return Base64.strict_decode64(encoded)
300
+ .force_encoding(Encoding::ASCII_8BIT)
301
+ end
302
+ end
303
+
304
+ # Multi-signature or unusual format: find the right entry
305
+ # Split on comma-separated dictionary members
306
+ value.split(",").each do |entry|
307
+ entry = entry.strip
308
+ if entry.start_with?(prefix)
309
+ end_idx = entry.index(":", prefix.length)
310
+ if end_idx
311
+ encoded = entry[prefix.length...end_idx]
312
+ return Base64.strict_decode64(encoded)
313
+ .force_encoding(Encoding::ASCII_8BIT)
314
+ end
315
+ end
316
+ end
317
+
318
+ # Label not found via fast path — fall back to Starry
319
+ signature = parse_structured_dictionary(
320
+ value.encode(Encoding::US_ASCII), "signature"
321
+ )
322
+ fail_with_signature_not_found label unless signature.key?(label)
323
+ signature[label].value.force_encoding(Encoding::ASCII_8BIT)
324
+ rescue ArgumentError
325
+ # Base64 decode failed — fall back to Starry
326
+ signature = parse_structured_dictionary(
327
+ value.encode(Encoding::US_ASCII), "signature"
328
+ )
329
+ fail_with_signature_not_found label unless signature.key?(label)
330
+ signature[label].value.force_encoding(Encoding::ASCII_8BIT)
331
+ end
332
+
333
+ # Serializes parsed Starry items to their string representations
334
+ # without going through the generic Starry.serialize_item path.
335
+ #
336
+ # For simple items (no parameters): builds '"value"' directly.
337
+ # For items with parameters: falls back to Starry.serialize_item.
338
+ #
339
+ # @param items [Array<Starry::Item>] parsed items from signature-input
340
+ # @return [Array<String>] serialized component identifiers
341
+ def serialize_parsed_items(items)
342
+ items.map do |item|
343
+ if item.parameters.empty?
344
+ "\"#{item.value}\""
345
+ else
346
+ Starry.serialize_item(item)
347
+ end
348
+ end
349
+ end
350
+
226
351
  def parse_structured_dictionary(str, field_name = nil)
227
352
  Starry.parse_dictionary(str)
228
353
  rescue Starry::ParseError => _