open_rosa 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,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "nokogiri"
5
+
6
+ module OpenRosa
7
+ # Represents a parsed OpenRosa form submission
8
+ #
9
+ # Handles parsing of multipart MIME submissions containing:
10
+ # - XML submission file (required, named "xml_submission_file")
11
+ # - Media attachments (optional, images/audio/video)
12
+ #
13
+ # Per OpenRosa spec:
14
+ # - POST to /submission with multipart/form-data
15
+ # - XML part must be named "xml_submission_file"
16
+ # - Other parts are treated as media attachments
17
+ class Submission # rubocop:disable Metrics/ClassLength
18
+ attr_reader :form_id, :instance_id, :data, :attachments, :metadata, :raw_xml
19
+
20
+ class ParseError < StandardError; end
21
+
22
+ # rubocop:disable Metrics/ParameterLists
23
+ def initialize(form_id:, instance_id:, data:, raw_xml:, attachments: [], metadata: {})
24
+ # rubocop:enable Metrics/ParameterLists
25
+ @form_id = form_id
26
+ @instance_id = instance_id
27
+ @data = data
28
+ @attachments = attachments
29
+ @metadata = metadata
30
+ @raw_xml = raw_xml
31
+ end
32
+
33
+ class << self
34
+ # Parse a Rack request into a Submission object
35
+ #
36
+ # @param rack_request [Rack::Request] The incoming request
37
+ # @return [Submission] Parsed submission
38
+ # @raise [ParseError] If submission is invalid
39
+ def parse(rack_request)
40
+ validate_request!(rack_request)
41
+ raw_xml = extract_raw_xml(rack_request)
42
+ doc = parse_xml(raw_xml)
43
+
44
+ build_submission(doc, rack_request, raw_xml)
45
+ end
46
+
47
+ private
48
+
49
+ def validate_request!(rack_request)
50
+ raise ParseError, "Request must be POST" unless rack_request.post?
51
+ raise ParseError, "Content-Type must be multipart/form-data" unless multipart?(rack_request)
52
+ end
53
+
54
+ def extract_raw_xml(rack_request)
55
+ xml_param = rack_request.params["xml_submission_file"]
56
+ raise ParseError, "Missing xml_submission_file parameter" unless xml_param
57
+
58
+ raw_xml = read_xml_param(xml_param)
59
+ raise ParseError, "Empty xml_submission_file" if raw_xml.nil? || raw_xml.empty?
60
+
61
+ raw_xml
62
+ end
63
+
64
+ def read_xml_param(xml_param)
65
+ if xml_param.respond_to?(:read)
66
+ xml_param.read
67
+ elsif xml_param.is_a?(Hash) && xml_param[:tempfile]
68
+ xml_param[:tempfile].read
69
+ else
70
+ xml_param.to_s
71
+ end
72
+ end
73
+
74
+ def build_submission(doc, rack_request, raw_xml)
75
+ new(
76
+ form_id: extract_form_id(doc),
77
+ instance_id: extract_instance_id(doc),
78
+ data: extract_data(doc),
79
+ attachments: extract_attachments(rack_request),
80
+ metadata: extract_metadata(doc),
81
+ raw_xml: raw_xml
82
+ )
83
+ end
84
+
85
+ def multipart?(request)
86
+ content_type = request.content_type
87
+ content_type&.start_with?("multipart/form-data")
88
+ end
89
+
90
+ def parse_xml(raw_xml)
91
+ Nokogiri::XML(raw_xml) do |config|
92
+ config.strict.nonet
93
+ end
94
+ rescue Nokogiri::XML::SyntaxError => e
95
+ raise ParseError, "Invalid XML: #{e.message}"
96
+ end
97
+
98
+ def extract_form_id(doc)
99
+ # The root element's name or id attribute is the form ID
100
+ root = doc.root
101
+ raise ParseError, "XML document has no root element" unless root
102
+
103
+ # Try id attribute first, then use element name
104
+ root["id"] || root.name
105
+ end
106
+
107
+ def extract_instance_id(doc)
108
+ # Look for meta/instanceID element
109
+ instance_id_node = doc.at_xpath("//meta/instanceID") ||
110
+ doc.at_xpath("//*[local-name()='instanceID']")
111
+
112
+ instance_id_node&.text&.strip
113
+ end
114
+
115
+ def extract_data(doc)
116
+ root = doc.root
117
+ data = {}
118
+
119
+ # Recursively extract all leaf nodes as data
120
+ extract_node_data(root, data)
121
+
122
+ data
123
+ end
124
+
125
+ def extract_node_data(node, data, prefix = nil)
126
+ node.element_children.each do |child|
127
+ # Skip meta elements
128
+ next if child.name == "meta"
129
+
130
+ field_name = prefix ? "#{prefix}/#{child.name}" : child.name
131
+
132
+ if child.element_children.empty?
133
+ # Leaf node - extract value
134
+ data[field_name] = child.text.strip
135
+ else
136
+ # Has children - recurse (for groups/repeats)
137
+ extract_node_data(child, data, field_name)
138
+ end
139
+ end
140
+ end
141
+
142
+ def extract_metadata(doc)
143
+ metadata = {}
144
+
145
+ # Extract common metadata fields
146
+ meta_node = doc.at_xpath("//meta") || doc.at_xpath("//*[local-name()='meta']")
147
+ return metadata unless meta_node
148
+
149
+ ["instanceID", "timeStart", "timeEnd", "deviceID", "userID"].each do |field|
150
+ node = meta_node.at_xpath(field) || meta_node.at_xpath("*[local-name()='#{field}']")
151
+ metadata[field.to_sym] = node.text.strip if node
152
+ end
153
+
154
+ metadata
155
+ end
156
+
157
+ def extract_attachments(rack_request)
158
+ rack_request.params.filter_map do |name, value|
159
+ next if name == "xml_submission_file"
160
+ next unless value.is_a?(Hash) && value[:tempfile]
161
+
162
+ Attachment.new(
163
+ filename: value[:filename],
164
+ content_type: value[:type] || "application/octet-stream",
165
+ size: value[:tempfile].size,
166
+ tempfile: value[:tempfile]
167
+ )
168
+ end
169
+ end
170
+ end
171
+
172
+ # Represents an uploaded file attachment
173
+ class Attachment
174
+ attr_reader :filename, :content_type, :size, :tempfile
175
+
176
+ def initialize(filename:, content_type:, size:, tempfile:)
177
+ @filename = filename
178
+ @content_type = content_type
179
+ @size = size
180
+ @tempfile = tempfile
181
+ end
182
+
183
+ # Read the file contents
184
+ def read
185
+ tempfile.rewind
186
+ tempfile.read
187
+ end
188
+
189
+ # Get the file path (useful for moving/copying)
190
+ def path
191
+ tempfile.path
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module OpenRosa
6
+ # Generates XForm XML from Form definitions
7
+ # rubocop:disable Metrics/ClassLength
8
+ class XForm
9
+ XFORMS_NS = "http://www.w3.org/2002/xforms"
10
+ XHTML_NS = "http://www.w3.org/1999/xhtml"
11
+
12
+ def initialize(form_class)
13
+ @form_class = form_class
14
+ @form_id = form_class.form_id
15
+ @version = form_class.version
16
+ @name = form_class.name || form_class.form_id
17
+ @fields = form_class.fields
18
+ end
19
+
20
+ def to_xml
21
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
22
+ xml.html(xform_namespaces) do
23
+ generate_head(xml)
24
+ generate_body(xml)
25
+ end
26
+ end
27
+
28
+ builder.to_xml
29
+ end
30
+
31
+ def xform_namespaces
32
+ {
33
+ "xmlns" => XFORMS_NS,
34
+ "xmlns:h" => XHTML_NS,
35
+ "xmlns:ev" => "http://www.w3.org/2001/xml-events",
36
+ "xmlns:xsd" => "http://www.w3.org/2001/XMLSchema",
37
+ "xmlns:jr" => "http://openrosa.org/javarosa"
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def generate_head(xml)
44
+ xml.head do
45
+ xml.title(@name, xmlns: XHTML_NS)
46
+ xml.model do
47
+ generate_instance(xml)
48
+ generate_bindings(xml)
49
+ end
50
+ end
51
+ end
52
+
53
+ def generate_instance(xml)
54
+ xml.instance do
55
+ xml.send(@form_id.to_sym, id: @form_id, version: @version) do
56
+ generate_instance_fields(xml, @fields)
57
+ generate_metadata(xml)
58
+ end
59
+ end
60
+ end
61
+
62
+ def generate_instance_fields(xml, fields, _path_prefix = nil)
63
+ fields.each do |field|
64
+ case field
65
+ when Fields::Group, Fields::Repeat
66
+ xml.send(field.name.to_sym) do
67
+ generate_instance_fields(xml, field.fields, field.name)
68
+ end
69
+ else
70
+ xml.send(field.name.to_sym)
71
+ end
72
+ end
73
+ end
74
+
75
+ def generate_metadata(xml)
76
+ xml.meta do
77
+ xml.instanceID
78
+ end
79
+ end
80
+
81
+ def generate_bindings(xml)
82
+ generate_field_bindings(xml, @fields, "/#{@form_id}")
83
+ end
84
+
85
+ def generate_field_bindings(xml, fields, path_prefix)
86
+ fields.each do |field|
87
+ nodeset = "#{path_prefix}/#{field.name}"
88
+ generate_binding_for_field(xml, field, nodeset)
89
+ end
90
+ end
91
+
92
+ def generate_binding_for_field(xml, field, nodeset)
93
+ case field
94
+ when Fields::Group
95
+ xml.bind(nodeset: nodeset, relevant: field.relevant) if field.relevant
96
+ generate_field_bindings(xml, field.fields, nodeset)
97
+ when Fields::Repeat
98
+ generate_field_bindings(xml, field.fields, nodeset)
99
+ else
100
+ xml.bind(build_bind_attrs(field, nodeset))
101
+ end
102
+ end
103
+
104
+ def build_bind_attrs(field, nodeset)
105
+ attrs = { nodeset: nodeset, type: binding_type(field) }
106
+ attrs[:required] = "true()" if field.required
107
+ attrs[:constraint] = field.constraint if field.respond_to?(:constraint) && field.constraint
108
+ attrs
109
+ end
110
+
111
+ def binding_type(field)
112
+ case field
113
+ when Fields::Input, Fields::Range
114
+ field.type.to_s
115
+ when Fields::Upload
116
+ "binary"
117
+ else
118
+ "string"
119
+ end
120
+ end
121
+
122
+ def generate_body(xml)
123
+ xml.body(xmlns: XHTML_NS) do
124
+ generate_controls(xml, @fields, "/#{@form_id}")
125
+ end
126
+ end
127
+
128
+ def generate_controls(xml, fields, path_prefix)
129
+ fields.each do |field|
130
+ ref = "#{path_prefix}/#{field.name}"
131
+ generate_control_for_field(xml, field, ref)
132
+ end
133
+ end
134
+
135
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
136
+ def generate_control_for_field(xml, field, ref)
137
+ case field
138
+ when Fields::Input then generate_input_control(xml, field, ref)
139
+ when Fields::Select1 then generate_select1_control(xml, field, ref)
140
+ when Fields::Select then generate_select_control(xml, field, ref)
141
+ when Fields::Boolean then generate_boolean_control(xml, field, ref)
142
+ when Fields::Upload then generate_upload_control(xml, field, ref)
143
+ when Fields::Range then generate_range_control(xml, field, ref)
144
+ when Fields::Trigger then generate_trigger_control(xml, field, ref)
145
+ when Fields::Group then generate_group_control(xml, field, ref)
146
+ when Fields::Repeat then generate_repeat_control(xml, field, ref)
147
+ end
148
+ end
149
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength
150
+
151
+ def generate_input_control(xml, field, ref)
152
+ xml.input(ref: ref) do
153
+ add_label_and_hint(xml, field)
154
+ end
155
+ end
156
+
157
+ def generate_select1_control(xml, field, ref)
158
+ xml.select1(ref: ref) do
159
+ add_label_and_hint(xml, field)
160
+ generate_choices(xml, field.choices)
161
+ end
162
+ end
163
+
164
+ def generate_select_control(xml, field, ref)
165
+ xml.select(ref: ref) do
166
+ add_label_and_hint(xml, field)
167
+ generate_choices(xml, field.choices)
168
+ end
169
+ end
170
+
171
+ def generate_boolean_control(xml, field, ref)
172
+ xml.select1(ref: ref) do
173
+ add_label_and_hint(xml, field)
174
+ generate_boolean_choices(xml)
175
+ end
176
+ end
177
+
178
+ def generate_boolean_choices(xml)
179
+ xml.item do
180
+ xml.label "Yes"
181
+ xml.value "true"
182
+ end
183
+ xml.item do
184
+ xml.label "No"
185
+ xml.value "false"
186
+ end
187
+ end
188
+
189
+ def generate_upload_control(xml, field, ref)
190
+ xml.upload(ref: ref, mediatype: field.mediatype) do
191
+ add_label_and_hint(xml, field)
192
+ end
193
+ end
194
+
195
+ def generate_range_control(xml, field, ref)
196
+ xml.range(ref: ref, start: field.start, end: field.end, step: field.step) do
197
+ add_label_and_hint(xml, field)
198
+ end
199
+ end
200
+
201
+ def generate_trigger_control(xml, field, ref)
202
+ xml.trigger(ref: ref) do
203
+ add_label_and_hint(xml, field)
204
+ end
205
+ end
206
+
207
+ def generate_group_control(xml, field, ref)
208
+ xml.group(ref: ref) do
209
+ xml.label field.label if field.label
210
+ generate_controls(xml, field.fields, ref)
211
+ end
212
+ end
213
+
214
+ def generate_repeat_control(xml, field, ref)
215
+ xml.repeat(nodeset: ref) do
216
+ xml.label field.label if field.label
217
+ generate_controls(xml, field.fields, ref)
218
+ end
219
+ end
220
+
221
+ def add_label_and_hint(xml, field)
222
+ xml.label field.label if field.label
223
+ xml.hint field.hint if field.hint
224
+ end
225
+
226
+ def generate_choices(xml, choices)
227
+ generate_array_choices(xml, choices) if choices.is_a?(Array)
228
+ generate_hash_choices(xml, choices) if choices.is_a?(Hash)
229
+ end
230
+
231
+ def generate_array_choices(xml, choices)
232
+ choices.each do |choice|
233
+ xml.item do
234
+ xml.label choice
235
+ xml.value choice
236
+ end
237
+ end
238
+ end
239
+
240
+ def generate_hash_choices(xml, choices)
241
+ choices.each do |label, value|
242
+ xml.item do
243
+ xml.label label
244
+ xml.value value
245
+ end
246
+ end
247
+ end
248
+ end
249
+ # rubocop:enable Metrics/ClassLength
250
+ end
data/lib/open_rosa.rb ADDED
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "open_rosa/version"
4
+ require_relative "open_rosa/fields/base"
5
+ require_relative "open_rosa/fields/input"
6
+ require_relative "open_rosa/fields/select1"
7
+ require_relative "open_rosa/fields/select"
8
+ require_relative "open_rosa/fields/upload"
9
+ require_relative "open_rosa/fields/range"
10
+ require_relative "open_rosa/fields/trigger"
11
+ require_relative "open_rosa/fields/boolean"
12
+ require_relative "open_rosa/fields/group"
13
+ require_relative "open_rosa/fields/repeat"
14
+ require_relative "open_rosa/field_context"
15
+ require_relative "open_rosa/form_dsl"
16
+ require_relative "open_rosa/form"
17
+ require_relative "open_rosa/xform"
18
+ require_relative "open_rosa/form_list"
19
+ require_relative "open_rosa/submission"
20
+ require_relative "open_rosa/media_file"
21
+ require_relative "open_rosa/manifest"
22
+ require_relative "open_rosa/middleware"
23
+
24
+ module OpenRosa
25
+ class Error < StandardError; end
26
+ # Your code goes here...
27
+ end
data/sig/openrosa.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module OpenRosa
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: open_rosa
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Alex Watt
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-01-10 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.15'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.15'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rack
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ description:
42
+ email:
43
+ - alex@alexcwatt.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - CHANGELOG.md
49
+ - CLAUDE.md
50
+ - LICENSE.txt
51
+ - README.md
52
+ - Rakefile
53
+ - examples/authentication.rb
54
+ - examples/rack_app.rb
55
+ - examples/rails_integration.rb
56
+ - lib/open_rosa.rb
57
+ - lib/open_rosa/field_context.rb
58
+ - lib/open_rosa/fields/base.rb
59
+ - lib/open_rosa/fields/boolean.rb
60
+ - lib/open_rosa/fields/group.rb
61
+ - lib/open_rosa/fields/input.rb
62
+ - lib/open_rosa/fields/range.rb
63
+ - lib/open_rosa/fields/repeat.rb
64
+ - lib/open_rosa/fields/select.rb
65
+ - lib/open_rosa/fields/select1.rb
66
+ - lib/open_rosa/fields/trigger.rb
67
+ - lib/open_rosa/fields/upload.rb
68
+ - lib/open_rosa/form.rb
69
+ - lib/open_rosa/form_dsl.rb
70
+ - lib/open_rosa/form_list.rb
71
+ - lib/open_rosa/manifest.rb
72
+ - lib/open_rosa/media_file.rb
73
+ - lib/open_rosa/middleware.rb
74
+ - lib/open_rosa/submission.rb
75
+ - lib/open_rosa/version.rb
76
+ - lib/open_rosa/xform.rb
77
+ - sig/openrosa.rbs
78
+ homepage: https://github.com/alexcwatt/openrosa-ruby
79
+ licenses:
80
+ - MIT
81
+ metadata:
82
+ homepage_uri: https://github.com/alexcwatt/openrosa-ruby
83
+ source_code_uri: https://github.com/alexcwatt/openrosa-ruby
84
+ changelog_uri: https://github.com/alexcwatt/openrosa-ruby/blob/main/CHANGELOG.md
85
+ rubygems_mfa_required: 'true'
86
+ post_install_message:
87
+ rdoc_options: []
88
+ require_paths:
89
+ - lib
90
+ required_ruby_version: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: 3.2.0
95
+ required_rubygems_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ requirements: []
101
+ rubygems_version: 3.5.23
102
+ signing_key:
103
+ specification_version: 4
104
+ summary: Manage OpenRosa forms.
105
+ test_files: []