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.
- checksums.yaml +7 -0
- data/.rspec +4 -0
- data/CHANGELOG.md +39 -0
- data/CONTRIBUTING.md +77 -0
- data/LICENSE.txt +22 -0
- data/README.md +437 -0
- data/Rakefile +9 -0
- data/examples/rails_controller_example.rb +89 -0
- data/lib/toon-parser/api.rb +166 -0
- data/lib/toon-parser/controller_helpers.rb +80 -0
- data/lib/toon-parser/parser.rb +375 -0
- data/lib/toon-parser/railtie.rb +34 -0
- data/lib/toon-parser/serializer.rb +137 -0
- data/lib/toon-parser/version.rb +6 -0
- data/lib/toon-parser.rb +66 -0
- data/toon-parser.gemspec +37 -0
- metadata +105 -0
|
@@ -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
|
+
|