toon-parser 0.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.
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module ToonParser
7
+ # API helpers for handling TOON-formatted requests and responses
8
+ class API
9
+ class APIError < Error; end
10
+
11
+ def initialize(base_url, headers: {})
12
+ @base_url = base_url
13
+ @default_headers = {
14
+ "Content-Type" => "application/toon",
15
+ "Accept" => "application/toon"
16
+ }.merge(headers)
17
+ end
18
+
19
+ # Make a GET request and parse TOON response
20
+ #
21
+ # @param path [String] API endpoint path
22
+ # @param headers [Hash] Additional headers
23
+ # @return [Hash, Array] Parsed response data
24
+ def get(path, headers: {})
25
+ uri = URI.join(@base_url, path)
26
+ http = Net::HTTP.new(uri.host, uri.port)
27
+ http.use_ssl = uri.scheme == "https"
28
+
29
+ request = Net::HTTP::Get.new(uri.request_uri)
30
+ merge_headers(request, headers)
31
+
32
+ response = http.request(request)
33
+ handle_response(response)
34
+ end
35
+
36
+ # Make a POST request with TOON body and parse TOON response
37
+ #
38
+ # @param path [String] API endpoint path
39
+ # @param data [Hash, Array] Data to send (will be serialized to TOON)
40
+ # @param headers [Hash] Additional headers
41
+ # @return [Hash, Array] Parsed response data
42
+ def post(path, data: nil, headers: {})
43
+ uri = URI.join(@base_url, path)
44
+ http = Net::HTTP.new(uri.host, uri.port)
45
+ http.use_ssl = uri.scheme == "https"
46
+
47
+ request = Net::HTTP::Post.new(uri.request_uri)
48
+ merge_headers(request, headers)
49
+
50
+ if data
51
+ toon_body = Serializer.new.serialize(data)
52
+ request.body = toon_body
53
+ end
54
+
55
+ response = http.request(request)
56
+ handle_response(response)
57
+ end
58
+
59
+ # Make a PUT request with TOON body and parse TOON response
60
+ #
61
+ # @param path [String] API endpoint path
62
+ # @param data [Hash, Array] Data to send (will be serialized to TOON)
63
+ # @param headers [Hash] Additional headers
64
+ # @return [Hash, Array] Parsed response data
65
+ def put(path, data: nil, headers: {})
66
+ uri = URI.join(@base_url, path)
67
+ http = Net::HTTP.new(uri.host, uri.port)
68
+ http.use_ssl = uri.scheme == "https"
69
+
70
+ request = Net::HTTP::Put.new(uri.request_uri)
71
+ merge_headers(request, headers)
72
+
73
+ if data
74
+ toon_body = Serializer.new.serialize(data)
75
+ request.body = toon_body
76
+ end
77
+
78
+ response = http.request(request)
79
+ handle_response(response)
80
+ end
81
+
82
+ # Make a PATCH request with TOON body and parse TOON response
83
+ #
84
+ # @param path [String] API endpoint path
85
+ # @param data [Hash, Array] Data to send (will be serialized to TOON)
86
+ # @param headers [Hash] Additional headers
87
+ # @return [Hash, Array] Parsed response data
88
+ def patch(path, data: nil, headers: {})
89
+ uri = URI.join(@base_url, path)
90
+ http = Net::HTTP.new(uri.host, uri.port)
91
+ http.use_ssl = uri.scheme == "https"
92
+
93
+ request = Net::HTTP::Patch.new(uri.request_uri)
94
+ merge_headers(request, headers)
95
+
96
+ if data
97
+ toon_body = Serializer.new.serialize(data)
98
+ request.body = toon_body
99
+ end
100
+
101
+ response = http.request(request)
102
+ handle_response(response)
103
+ end
104
+
105
+ # Make a DELETE request and parse TOON response
106
+ #
107
+ # @param path [String] API endpoint path
108
+ # @param headers [Hash] Additional headers
109
+ # @return [Hash, Array] Parsed response data
110
+ def delete(path, headers: {})
111
+ uri = URI.join(@base_url, path)
112
+ http = Net::HTTP.new(uri.host, uri.port)
113
+ http.use_ssl = uri.scheme == "https"
114
+
115
+ request = Net::HTTP::Delete.new(uri.request_uri)
116
+ merge_headers(request, headers)
117
+
118
+ response = http.request(request)
119
+ handle_response(response)
120
+ end
121
+
122
+ private
123
+
124
+ def merge_headers(request, additional_headers)
125
+ all_headers = @default_headers.merge(additional_headers)
126
+ all_headers.each do |key, value|
127
+ request[key] = value
128
+ end
129
+ end
130
+
131
+ def handle_response(response)
132
+ unless response.is_a?(Net::HTTPSuccess)
133
+ raise APIError, "HTTP #{response.code}: #{response.message}"
134
+ end
135
+
136
+ content_type = response["Content-Type"] || ""
137
+ body = response.body
138
+
139
+ # Check if response is TOON format
140
+ if content_type.include?("application/toon") || content_type.include?("text/toon")
141
+ Parser.new.parse(body)
142
+ else
143
+ # Try to parse anyway if it looks like TOON
144
+ if body.strip.match?(/^[a-zA-Z_].*\[.*\]\{.*\}:|^\[.*\]\{.*\}:/)
145
+ Parser.new.parse(body)
146
+ else
147
+ body
148
+ end
149
+ end
150
+ end
151
+ end
152
+
153
+ # Convenience method to create an API client
154
+ #
155
+ # @param base_url [String] Base URL for the API
156
+ # @param headers [Hash] Default headers
157
+ # @return [API] API client instance
158
+ #
159
+ # @example
160
+ # api = ToonParser.api("https://api.example.com")
161
+ # users = api.get("/users")
162
+ def self.api(base_url, headers: {})
163
+ API.new(base_url, headers: headers)
164
+ end
165
+ end
166
+
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "rails"
5
+
6
+ module ToonParser
7
+ # Controller helpers for TOON format
8
+ module ControllerHelpers
9
+ extend ActiveSupport::Concern
10
+
11
+ included do
12
+ # Set default format to TOON for API controllers
13
+ # Override in your controller if needed:
14
+ # before_action :set_toon_format
15
+ end
16
+
17
+ # Render TOON response
18
+ #
19
+ # @param data [Hash, Array] Data to render as TOON
20
+ # @param options [Hash] Render options
21
+ #
22
+ # @example
23
+ # def index
24
+ # @users = User.all
25
+ # render toon: { users: @users }
26
+ # end
27
+ def render_toon(data, options = {})
28
+ render options.merge(toon: data)
29
+ end
30
+
31
+ # Parse TOON request body
32
+ #
33
+ # @return [Hash, Array] Parsed request body
34
+ #
35
+ # @example
36
+ # def create
37
+ # data = parse_toon_request
38
+ # @user = User.create(data)
39
+ # render toon: @user
40
+ # end
41
+ def parse_toon_request
42
+ return {} unless request.content_type&.include?("application/toon")
43
+
44
+ ToonParser.parse(request.body.read)
45
+ end
46
+
47
+ # Set default format to TOON
48
+ #
49
+ # @example
50
+ # class ApiController < ApplicationController
51
+ # before_action :set_toon_format
52
+ # end
53
+ def set_toon_format
54
+ request.format = :toon if request.format == :html || request.format == Mime[:html]
55
+ end
56
+
57
+ # Respond to TOON format
58
+ #
59
+ # @example
60
+ # respond_to do |format|
61
+ # format.toon { render toon: @users }
62
+ # format.json { render json: @users }
63
+ # end
64
+ def respond_to_toon(data, options = {})
65
+ respond_to do |format|
66
+ format.toon { render options.merge(toon: data) }
67
+ format.json { render options.merge(json: data) }
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ # Auto-include in ActionController::Base if Rails is available
74
+ if defined?(ActionController::Base)
75
+ ActionController::Base.include ToonParser::ControllerHelpers
76
+ end
77
+ rescue LoadError
78
+ # Rails is not available, skip controller helpers
79
+ end
80
+
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ToonParser
4
+ # Parser for TOON (Token-Oriented Object Notation) format
5
+ class Parser
6
+ class ParseError < ToonParser::Error; end
7
+
8
+ def initialize
9
+ @lines = []
10
+ @index = 0
11
+ end
12
+
13
+ # Parse a TOON string into Ruby objects
14
+ #
15
+ # @param toon_string [String] The TOON formatted string
16
+ # @return [Hash, Array] Parsed Ruby object
17
+ def parse(toon_string)
18
+ @lines = toon_string.lines.map(&:chomp).reject(&:empty?)
19
+ @index = 0
20
+ parse_value
21
+ end
22
+
23
+ private
24
+
25
+ def parse_value
26
+ return nil if @index >= @lines.size
27
+
28
+ line = @lines[@index]
29
+ indent = get_indent(line)
30
+ content = line.strip
31
+
32
+ # Check for array patterns first (before key-value pairs)
33
+ if content.match?(/^[a-zA-Z_][a-zA-Z0-9_]*\[.*\]\{.*\}:?$/)
34
+ # Array declaration with schema: key[size]{schema}:
35
+ parse_array_with_schema(indent)
36
+ elsif content.match?(/^\[.*\]\{.*\}:?$/)
37
+ # Inline array with schema: [size]{schema}:
38
+ parse_inline_array_with_schema(indent)
39
+ elsif content.match?(/^[a-zA-Z_][a-zA-Z0-9_]*\[\d+\]:?$/)
40
+ # Simple array with key: key[size]:
41
+ parse_simple_array_with_key(indent)
42
+ elsif content.match?(/^\[\d+\]:?$/)
43
+ # Simple array: [size]:
44
+ parse_simple_array(indent)
45
+ elsif content.start_with?("{") && content.end_with?("}:")
46
+ # Object schema: {field1,field2}:
47
+ parse_object_schema(indent)
48
+ elsif content.include?(":") && !content.start_with?(":")
49
+ # Key-value pair (object property)
50
+ parse_object(indent)
51
+ else
52
+ # Primitive value or array row
53
+ parse_primitive_or_row(indent)
54
+ end
55
+ end
56
+
57
+ def parse_object(base_indent = 0)
58
+ result = {}
59
+ current_indent = base_indent
60
+
61
+ while @index < @lines.size
62
+ line = @lines[@index]
63
+ indent = get_indent(line)
64
+
65
+ # Stop if we've gone back to a higher level
66
+ break if indent < current_indent
67
+
68
+ # Skip if not at current level
69
+ if indent > current_indent
70
+ @index += 1
71
+ next
72
+ end
73
+
74
+ content = line.strip
75
+
76
+ # Parse key: value
77
+ if content.include?(":")
78
+ key, value_part = content.split(":", 2).map(&:strip)
79
+ @index += 1
80
+
81
+ # Check if value is on next line(s)
82
+ if value_part.empty? && @index < @lines.size
83
+ next_line = @lines[@index]
84
+ next_indent = get_indent(next_line)
85
+
86
+ if next_indent > indent
87
+ # Value is indented on next line
88
+ result[key] = parse_value
89
+ else
90
+ # Empty value
91
+ result[key] = nil
92
+ end
93
+ else
94
+ # Value is inline
95
+ result[key] = parse_inline_value(value_part)
96
+ end
97
+ else
98
+ @index += 1
99
+ end
100
+ end
101
+
102
+ result
103
+ end
104
+
105
+ def parse_array_with_schema(base_indent)
106
+ line = @lines[@index].strip
107
+ @index += 1
108
+
109
+ # Parse: key[size]{schema}:
110
+ match = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]\{([^}]+)\}:?$/)
111
+ raise ParseError, "Invalid array schema format: #{line}" unless match
112
+
113
+ key = match[1]
114
+ size = match[2].to_i
115
+ schema = match[3].split(",").map(&:strip)
116
+
117
+ # Parse array rows
118
+ array = []
119
+ current_indent = base_indent
120
+
121
+ size.times do
122
+ break if @index >= @lines.size
123
+
124
+ line = @lines[@index]
125
+ indent = get_indent(line)
126
+
127
+ # Skip if not indented properly
128
+ if indent <= current_indent
129
+ break
130
+ end
131
+
132
+ content = line.strip
133
+ values = parse_row(content)
134
+
135
+ if values.size == schema.size
136
+ obj = {}
137
+ schema.each_with_index do |field, i|
138
+ obj[field] = parse_primitive_value(values[i])
139
+ end
140
+ array << obj
141
+ end
142
+
143
+ @index += 1
144
+ end
145
+
146
+ { key => array }
147
+ end
148
+
149
+ def parse_inline_array_with_schema(base_indent)
150
+ line = @lines[@index].strip
151
+ @index += 1
152
+
153
+ # Parse: [size]{schema}:
154
+ match = line.match(/^\[(\d+)\]\{([^}]+)\}:?$/)
155
+ raise ParseError, "Invalid inline array schema format: #{line}" unless match
156
+
157
+ size = match[1].to_i
158
+ schema = match[2].split(",").map(&:strip)
159
+
160
+ array = []
161
+ current_indent = base_indent
162
+
163
+ size.times do
164
+ break if @index >= @lines.size
165
+
166
+ line = @lines[@index]
167
+ indent = get_indent(line)
168
+
169
+ if indent <= current_indent
170
+ break
171
+ end
172
+
173
+ content = line.strip
174
+ values = parse_row(content)
175
+
176
+ if values.size == schema.size
177
+ obj = {}
178
+ schema.each_with_index do |field, i|
179
+ obj[field] = parse_primitive_value(values[i])
180
+ end
181
+ array << obj
182
+ end
183
+
184
+ @index += 1
185
+ end
186
+
187
+ array
188
+ end
189
+
190
+ def parse_simple_array(base_indent)
191
+ line = @lines[@index].strip
192
+ @index += 1
193
+
194
+ # Parse: [size]:
195
+ match = line.match(/^\[(\d+)\]:?$/)
196
+ raise ParseError, "Invalid array format: #{line}" unless match
197
+
198
+ size = match[1].to_i
199
+ array = []
200
+ current_indent = base_indent
201
+
202
+ size.times do
203
+ break if @index >= @lines.size
204
+
205
+ line = @lines[@index]
206
+ indent = get_indent(line)
207
+
208
+ if indent <= current_indent
209
+ break
210
+ end
211
+
212
+ content = line.strip
213
+ array << parse_primitive_value(content)
214
+ @index += 1
215
+ end
216
+
217
+ array
218
+ end
219
+
220
+ def parse_simple_array_with_key(base_indent)
221
+ line = @lines[@index].strip
222
+ @index += 1
223
+
224
+ # Parse: key[size]:
225
+ match = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\[(\d+)\]:?$/)
226
+ raise ParseError, "Invalid array format: #{line}" unless match
227
+
228
+ key = match[1]
229
+ size = match[2].to_i
230
+ array = []
231
+ current_indent = base_indent
232
+
233
+ size.times do
234
+ break if @index >= @lines.size
235
+
236
+ line = @lines[@index]
237
+ indent = get_indent(line)
238
+
239
+ if indent <= current_indent
240
+ break
241
+ end
242
+
243
+ content = line.strip
244
+ array << parse_primitive_value(content)
245
+ @index += 1
246
+ end
247
+
248
+ { key => array }
249
+ end
250
+
251
+ def parse_object_schema(base_indent)
252
+ line = @lines[@index].strip
253
+ @index += 1
254
+
255
+ # Parse: {field1,field2}:
256
+ match = line.match(/^\{([^}]+)\}:?$/)
257
+ raise ParseError, "Invalid object schema format: #{line}" unless match
258
+
259
+ schema = match[1].split(",").map(&:strip)
260
+
261
+ # Expect one row following
262
+ if @index < @lines.size
263
+ line = @lines[@index]
264
+ indent = get_indent(line)
265
+
266
+ if indent > base_indent
267
+ content = line.strip
268
+ values = parse_row(content)
269
+
270
+ if values.size == schema.size
271
+ obj = {}
272
+ schema.each_with_index do |field, i|
273
+ obj[field] = parse_primitive_value(values[i])
274
+ end
275
+ @index += 1
276
+ return obj
277
+ end
278
+ end
279
+ end
280
+
281
+ {}
282
+ end
283
+
284
+ def parse_primitive_or_row(base_indent)
285
+ line = @lines[@index].strip
286
+ @index += 1
287
+
288
+ # Check if there are more indented lines (array rows)
289
+ if @index < @lines.size
290
+ next_line = @lines[@index]
291
+ next_indent = get_indent(next_line)
292
+
293
+ if next_indent > base_indent
294
+ # This is likely an array of rows
295
+ array = []
296
+ array << parse_primitive_value(line)
297
+
298
+ while @index < @lines.size
299
+ line = @lines[@index]
300
+ indent = get_indent(line)
301
+
302
+ break if indent <= base_indent
303
+
304
+ content = line.strip
305
+ array << parse_primitive_value(content)
306
+ @index += 1
307
+ end
308
+
309
+ return array
310
+ end
311
+ end
312
+
313
+ parse_primitive_value(line)
314
+ end
315
+
316
+ def parse_row(content)
317
+ # Split by comma, but handle quoted strings
318
+ result = []
319
+ current = ""
320
+ in_quotes = false
321
+ quote_char = nil
322
+
323
+ content.each_char do |char|
324
+ if (char == '"' || char == "'") && !in_quotes
325
+ in_quotes = true
326
+ quote_char = char
327
+ elsif char == quote_char && in_quotes
328
+ in_quotes = false
329
+ quote_char = nil
330
+ current += char
331
+ elsif char == "," && !in_quotes
332
+ result << current.strip
333
+ current = ""
334
+ else
335
+ current += char
336
+ end
337
+ end
338
+
339
+ result << current.strip unless current.empty?
340
+ result
341
+ end
342
+
343
+ def parse_primitive_value(value)
344
+ return nil if value == "null" || value.empty?
345
+
346
+ # Boolean
347
+ return true if value == "true"
348
+ return false if value == "false"
349
+
350
+ # Number
351
+ if value.match?(/^-?\d+$/)
352
+ return value.to_i
353
+ elsif value.match?(/^-?\d+\.\d+$/)
354
+ return value.to_f
355
+ end
356
+
357
+ # String (remove quotes if present)
358
+ if (value.start_with?('"') && value.end_with?('"')) ||
359
+ (value.start_with?("'") && value.end_with?("'"))
360
+ return value[1..-2]
361
+ end
362
+
363
+ value
364
+ end
365
+
366
+ def parse_inline_value(value)
367
+ value.strip.empty? ? nil : parse_primitive_value(value.strip)
368
+ end
369
+
370
+ def get_indent(line)
371
+ line.size - line.lstrip.size
372
+ end
373
+ end
374
+ end
375
+
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "rails"
5
+
6
+ module ToonParser
7
+ # Rails integration for TOON format
8
+ class Railtie < Rails::Railtie
9
+ initializer "toon_parser.register_mime_type" do |app|
10
+ # Register TOON MIME type
11
+ Mime::Type.register "application/toon", :toon
12
+ Mime::Type.register_alias "application/toon", :toon
13
+ end
14
+
15
+ initializer "toon_parser.register_renderer" do |app|
16
+ # Register TOON renderer
17
+ ActionController::Renderers.add :toon do |obj, options|
18
+ self.content_type ||= Mime[:toon]
19
+ self.response_body = ToonParser.serialize(obj)
20
+ end
21
+ end
22
+
23
+ initializer "toon_parser.parameter_parser" do |app|
24
+ # Register TOON parameter parser for request bodies
25
+ ActionDispatch::Request.parameter_parsers[:toon] = lambda do |raw_post|
26
+ ToonParser.parse(raw_post)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ rescue LoadError
32
+ # Rails is not available, skip Railtie
33
+ end
34
+