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,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module OpenRosa
6
+ # Generates OpenRosa Form List API XML responses
7
+ # Spec: https://docs.getodk.org/openrosa-form-list/
8
+ class FormList
9
+ XFORMS_LIST_NS = "http://openrosa.org/xforms/xformsList"
10
+
11
+ def initialize(forms, options = {})
12
+ @forms = forms
13
+ @verbose = options.fetch(:verbose, false)
14
+ @form_id = options[:form_id]
15
+ @base_url = options[:base_url]
16
+ @mount_path = options[:mount_path] || "/openrosa"
17
+ end
18
+
19
+ def to_xml
20
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
21
+ xml.xforms(xmlns: XFORMS_LIST_NS) do
22
+ filtered_forms.each do |form_class|
23
+ generate_xform_entry(xml, form_class)
24
+ end
25
+ end
26
+ end
27
+
28
+ builder.to_xml
29
+ end
30
+
31
+ private
32
+
33
+ def filtered_forms
34
+ return @forms unless @form_id
35
+
36
+ @forms.select { |form| form.form_id == @form_id }
37
+ end
38
+
39
+ def generate_xform_entry(xml, form_class)
40
+ xml.xform do
41
+ add_required_fields(xml, form_class)
42
+ add_verbose_fields(xml, form_class) if @verbose
43
+ add_optional_fields(xml, form_class)
44
+ end
45
+ end
46
+
47
+ def add_required_fields(xml, form_class)
48
+ xml.formID form_class.form_id
49
+ xml.name form_class.name if form_class.name
50
+ xml.version form_class.version if form_class.version
51
+ xml.hash_ form_class.form_hash
52
+ url = download_url_for(form_class)
53
+ xml.downloadUrl url if url
54
+ end
55
+
56
+ def download_url_for(form_class)
57
+ # Use explicit download_url if set on the form
58
+ return form_class.download_url if form_class.download_url
59
+
60
+ # Auto-generate from base_url if configured
61
+ unless @base_url
62
+ raise ArgumentError,
63
+ "Form '#{form_class.form_id}' has no download_url. " \
64
+ "Either set download_url on the form or configure base_url in the middleware."
65
+ end
66
+
67
+ "#{@base_url}#{@mount_path}/forms/#{form_class.form_id}"
68
+ end
69
+
70
+ def add_verbose_fields(xml, form_class)
71
+ xml.descriptionText form_class.description_text if form_class.description_text
72
+ xml.descriptionUrl form_class.description_url if form_class.description_url
73
+ end
74
+
75
+ def add_optional_fields(xml, form_class)
76
+ url = manifest_url_for(form_class)
77
+ xml.manifestUrl url if url
78
+ end
79
+
80
+ def manifest_url_for(form_class)
81
+ # Use explicit manifest_url if set on the form
82
+ return form_class.manifest_url if form_class.manifest_url
83
+
84
+ # Auto-generate from base_url if form has a manifest defined
85
+ return nil unless @base_url
86
+ return nil unless form_has_manifest?(form_class)
87
+
88
+ "#{@base_url}#{@mount_path}/manifests/#{form_class.form_id}"
89
+ end
90
+
91
+ def form_has_manifest?(form_class)
92
+ # Handle both class and instance (middleware passes instances)
93
+ klass = form_class.is_a?(Class) ? form_class : form_class.class
94
+ klass.respond_to?(:manifest) && klass.manifest
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module OpenRosa
6
+ # Represents a manifest of media files associated with a form.
7
+ # The manifest lists all supporting files (images, audio, video, entity lists)
8
+ # that need to be downloaded along with the form definition.
9
+ #
10
+ # @example Create a manifest with media files
11
+ # manifest = OpenRosa::Manifest.new
12
+ # manifest.add_media_file(
13
+ # OpenRosa::MediaFile.new(
14
+ # filename: "images/logo.png",
15
+ # hash: "md5:abc123",
16
+ # download_url: "https://example.com/media/logo.png"
17
+ # )
18
+ # )
19
+ # xml = manifest.to_xml
20
+ #
21
+ # @example Create a manifest with entity list
22
+ # manifest = OpenRosa::Manifest.new
23
+ # manifest.add_media_file(
24
+ # OpenRosa::MediaFile.new(
25
+ # filename: "entities.csv",
26
+ # hash: "md5:xyz789",
27
+ # download_url: "https://example.com/entities.csv",
28
+ # type: "entityList",
29
+ # integrity_url: "https://example.com/entities/integrity"
30
+ # )
31
+ # )
32
+ class Manifest
33
+ attr_reader :media_files
34
+
35
+ # Creates a new Manifest
36
+ #
37
+ # @param media_files [Array<MediaFile>] Optional array of MediaFile objects
38
+ def initialize(media_files: [])
39
+ @media_files = media_files
40
+ end
41
+
42
+ # Adds a media file to the manifest
43
+ #
44
+ # @param media_file [MediaFile] The media file to add
45
+ # @return [Manifest] self for chaining
46
+ def add_media_file(media_file)
47
+ @media_files << media_file
48
+ self
49
+ end
50
+
51
+ # Generates OpenRosa manifest XML
52
+ #
53
+ # @return [String] XML string conforming to OpenRosa manifest spec
54
+ def to_xml
55
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
56
+ xml.manifest(xmlns: "http://openrosa.org/xforms/xformsManifest") do
57
+ media_files.each do |media_file|
58
+ build_media_file(xml, media_file)
59
+ end
60
+ end
61
+ end
62
+
63
+ builder.to_xml
64
+ end
65
+
66
+ # Check if the manifest is empty
67
+ #
68
+ # @return [Boolean] true if no media files
69
+ def empty?
70
+ media_files.empty?
71
+ end
72
+
73
+ # Count of media files in the manifest
74
+ #
75
+ # @return [Integer] number of media files
76
+ def count
77
+ media_files.count
78
+ end
79
+
80
+ private
81
+
82
+ def build_media_file(xml, media_file)
83
+ # Add type attribute only if specified
84
+ if media_file.type
85
+ xml.mediaFile(type: media_file.type) do
86
+ build_media_file_elements(xml, media_file)
87
+ end
88
+ else
89
+ xml.mediaFile do
90
+ build_media_file_elements(xml, media_file)
91
+ end
92
+ end
93
+ end
94
+
95
+ def build_media_file_elements(xml, media_file)
96
+ xml.filename media_file.filename
97
+ # Use text approach to avoid conflict with Object#hash
98
+ xml.text("\n ")
99
+ xml.parent.add_child(Nokogiri::XML::Node.new("hash", xml.doc).tap { |n| n.content = media_file.hash })
100
+ xml.text("\n ")
101
+ xml.downloadUrl media_file.download_url
102
+ xml.integrityUrl media_file.integrity_url if media_file.integrity_url
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module OpenRosa
6
+ # Represents a media file (image, audio, video, entity list) associated with a form.
7
+ # Media files are listed in the form's manifest and downloaded separately from the form definition.
8
+ #
9
+ # @example Create a media file with manual hash
10
+ # media_file = OpenRosa::MediaFile.new(
11
+ # filename: "images/logo.png",
12
+ # hash: "md5:abc123def456",
13
+ # download_url: "https://example.com/media/logo.png"
14
+ # )
15
+ #
16
+ # @example Create a media file with automatic hash generation
17
+ # media_file = OpenRosa::MediaFile.new(
18
+ # filename: "images/logo.png",
19
+ # file: "/path/to/logo.png",
20
+ # download_url: "https://example.com/media/logo.png"
21
+ # )
22
+ #
23
+ # @example Create an entity list media file
24
+ # media_file = OpenRosa::MediaFile.new(
25
+ # filename: "entities.csv",
26
+ # hash: "md5:xyz789",
27
+ # download_url: "https://example.com/entities.csv",
28
+ # type: "entityList",
29
+ # integrity_url: "https://example.com/entities/integrity"
30
+ # )
31
+ class MediaFile
32
+ attr_reader :filename, :hash, :download_url, :type, :integrity_url
33
+
34
+ # Creates a new MediaFile
35
+ #
36
+ # @param filename [String] Unrooted file path (no drive letters, no absolute paths, no backslashes)
37
+ # @param hash [String, nil] MD5 hash in format "md5:..." (auto-generated if file provided)
38
+ # @param file [String, nil] Path to file for hash generation
39
+ # @param download_url [String] Full URI for downloading the file
40
+ # @param type [String, nil] Optional type (e.g., "entityList")
41
+ # @param integrity_url [String, nil] Required if type="entityList"
42
+ # @raise [ArgumentError] if filename is invalid, or if hash/file not provided, or entityList missing integrity_url
43
+ # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
44
+ def initialize(filename:, download_url:, hash: nil, file: nil, type: nil, integrity_url: nil)
45
+ # rubocop:enable Metrics/ParameterLists
46
+ @filename = validate_filename(filename)
47
+ @download_url = download_url
48
+ @type = type
49
+ @integrity_url = integrity_url
50
+
51
+ # Hash can be provided or generated from file
52
+ if hash
53
+ @hash = hash
54
+ elsif file
55
+ @hash = generate_hash(file)
56
+ else
57
+ raise ArgumentError, "Either hash or file parameter must be provided"
58
+ end
59
+
60
+ # Validate entityList requirements
61
+ validate_entity_list if type == "entityList"
62
+ end
63
+ # rubocop:enable Metrics/MethodLength
64
+
65
+ private
66
+
67
+ def validate_filename(filename)
68
+ # Check for Windows drive letters (C:, D:, etc.)
69
+ raise ArgumentError, "Filename cannot contain a drive letter: #{filename}" if filename.match?(/^[A-Za-z]:/)
70
+
71
+ # Check for relative paths (..)
72
+ if filename.include?("..")
73
+ raise ArgumentError, "Filename cannot contain relative path components (..): #{filename}"
74
+ end
75
+
76
+ # Check for absolute paths (starting with /)
77
+ raise ArgumentError, "Filename cannot be an absolute path: #{filename}" if filename.start_with?("/")
78
+
79
+ # Check for backslashes (Windows-style paths)
80
+ raise ArgumentError, "Filename cannot contain backslash characters: #{filename}" if filename.include?("\\")
81
+
82
+ filename
83
+ end
84
+
85
+ def generate_hash(file_path)
86
+ content = File.read(file_path)
87
+ digest = Digest::MD5.hexdigest(content)
88
+ "md5:#{digest}"
89
+ end
90
+
91
+ def validate_entity_list
92
+ return if integrity_url
93
+
94
+ raise ArgumentError, "integrity_url is required when type is 'entityList'"
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module OpenRosa
6
+ # Rack middleware for OpenRosa endpoints
7
+ #
8
+ # Provides endpoints for:
9
+ # - GET /formList - List available forms
10
+ # - GET /forms/:id - Download a specific form as XForm XML
11
+ # - POST /submission - Receive form submissions
12
+ # - HEAD /submission - Pre-flight check for submissions
13
+ #
14
+ # Example usage:
15
+ # use OpenRosa::Middleware do |config|
16
+ # config.forms = [MyForm, AnotherForm]
17
+ # config.mount_path = "/openrosa"
18
+ #
19
+ # # Handle submissions
20
+ # config.on_submission do |submission|
21
+ # # Save to database, process files, etc.
22
+ # MySubmission.create!(
23
+ # form_id: submission.form_id,
24
+ # data: submission.data,
25
+ # instance_id: submission.instance_id
26
+ # )
27
+ # "Thank you for your submission!"
28
+ # end
29
+ # end
30
+ class Middleware # rubocop:disable Metrics/ClassLength
31
+ OPENROSA_VERSION = "1.0"
32
+
33
+ attr_reader :app, :config
34
+
35
+ def initialize(app = nil, &block)
36
+ @app = app
37
+ @config = Configuration.new
38
+ block&.call(@config)
39
+ end
40
+
41
+ def call(env)
42
+ request = Rack::Request.new(env)
43
+
44
+ return delegate_to_app(env) unless handle_request?(request)
45
+
46
+ # Check authentication if configured
47
+ return unauthorized_response unless authenticated?(env, request)
48
+
49
+ route_request(request)
50
+ end
51
+
52
+ private
53
+
54
+ def delegate_to_app(env)
55
+ @app ? @app.call(env) : not_found_response
56
+ end
57
+
58
+ def handle_request?(request)
59
+ @app.nil? || openrosa_path?(request.path_info)
60
+ end
61
+
62
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
63
+ def route_request(request)
64
+ path = request.path_info
65
+ method = request.request_method
66
+
67
+ case [method, path]
68
+ when ["GET", "#{mount_path}/formList"]
69
+ handle_form_list(request)
70
+ when ["POST", "#{mount_path}/submission"]
71
+ handle_submission(request)
72
+ when ["HEAD", "#{mount_path}/submission"]
73
+ handle_submission_head(request)
74
+ else
75
+ # Check for form download pattern (GET /forms/:id)
76
+ if method == "GET" && (match = path.match(%r{^#{Regexp.escape(mount_path)}/forms/(.+)$}))
77
+ handle_form_download(request, match[1])
78
+ # Check for manifest download pattern (GET /manifests/:id)
79
+ elsif method == "GET" && (match = path.match(%r{^#{Regexp.escape(mount_path)}/manifests/(.+)$}))
80
+ handle_manifest(request, match[1])
81
+ else
82
+ delegate_to_app(request.env)
83
+ end
84
+ end
85
+ end
86
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity
87
+
88
+ def mount_path
89
+ @config.mount_path
90
+ end
91
+
92
+ def openrosa_path?(path)
93
+ path.start_with?(mount_path)
94
+ end
95
+
96
+ def handle_form_list(request)
97
+ form_list = FormList.new(available_forms, form_list_options(request))
98
+ openrosa_xml_response(form_list.to_xml)
99
+ end
100
+
101
+ def form_list_options(request)
102
+ {
103
+ form_id: request.params["formID"],
104
+ verbose: request.params["verbose"] == "true",
105
+ base_url: @config.base_url,
106
+ mount_path: mount_path
107
+ }
108
+ end
109
+
110
+ def handle_form_download(_request, form_id)
111
+ form = find_form(form_id)
112
+ return not_found_response unless form
113
+
114
+ xml = form.to_xml
115
+
116
+ openrosa_xml_response(xml)
117
+ end
118
+
119
+ def handle_manifest(_request, form_id)
120
+ form = find_form(form_id)
121
+ return not_found_response("Form not found") unless form
122
+
123
+ # Check if the form has a manifest method
124
+ return not_found_response("Manifest not found for this form") unless form.class.respond_to?(:manifest)
125
+
126
+ manifest = form.class.manifest
127
+ return not_found_response("Manifest not found for this form") if manifest.nil? || manifest.empty?
128
+
129
+ xml = manifest.to_xml
130
+
131
+ openrosa_xml_response(xml)
132
+ end
133
+
134
+ def available_forms
135
+ @config.forms.map do |form_class|
136
+ # If it's a class, instantiate it; otherwise use as-is
137
+ form_class.is_a?(Class) ? form_class.new : form_class
138
+ end
139
+ end
140
+
141
+ def find_form(form_id)
142
+ available_forms.find { |form| form.form_id == form_id }
143
+ end
144
+
145
+ def openrosa_xml_response(xml)
146
+ [
147
+ 200,
148
+ {
149
+ "Content-Type" => "text/xml; charset=utf-8",
150
+ "X-OpenRosa-Version" => OPENROSA_VERSION,
151
+ "Date" => Time.now.httpdate
152
+ },
153
+ [xml]
154
+ ]
155
+ end
156
+
157
+ def handle_submission(request)
158
+ submission = parse_submission(request)
159
+ return submission if submission.is_a?(Array) # Error response
160
+
161
+ message = process_submission(submission)
162
+ return message if message.is_a?(Array) # Error response
163
+
164
+ openrosa_submission_response(message || "Submission received successfully")
165
+ end
166
+
167
+ def parse_submission(request)
168
+ Submission.parse(request)
169
+ rescue Submission::ParseError => e
170
+ error_response(400, "Invalid submission: #{e.message}")
171
+ end
172
+
173
+ def process_submission(submission)
174
+ form_class = find_form_class_for_submission(submission)
175
+ execute_submission_handler(form_class, submission)
176
+ rescue StandardError => e
177
+ error_response(500, "Server error: #{e.message}")
178
+ end
179
+
180
+ def find_form_class_for_submission(submission)
181
+ @config.forms.find do |fc|
182
+ form = fc.is_a?(Class) ? fc.new : fc
183
+ form.form_id == submission.form_id
184
+ end
185
+ end
186
+
187
+ def execute_submission_handler(form_class, submission)
188
+ # Try form-specific handler first
189
+ form_handler_result = form_class&.handle_submission(submission) if form_class
190
+
191
+ if form_handler_result
192
+ form_handler_result
193
+ elsif @config.submission_handler
194
+ @config.submission_handler.call(submission)
195
+ else
196
+ "Submission received successfully"
197
+ end
198
+ end
199
+
200
+ def handle_submission_head(_request)
201
+ # HEAD request for pre-flight check
202
+ # Return max content length we'll accept (10MB default)
203
+ max_size = @config.max_submission_size || 10_485_760
204
+
205
+ [
206
+ 204,
207
+ {
208
+ "X-OpenRosa-Version" => OPENROSA_VERSION,
209
+ "X-OpenRosa-Accept-Content-Length" => max_size.to_s,
210
+ "Date" => Time.now.httpdate
211
+ },
212
+ []
213
+ ]
214
+ end
215
+
216
+ def openrosa_submission_response(message)
217
+ xml = build_openrosa_response_xml(message)
218
+
219
+ [
220
+ 201,
221
+ {
222
+ "Content-Type" => "text/xml; charset=utf-8",
223
+ "X-OpenRosa-Version" => OPENROSA_VERSION,
224
+ "Date" => Time.now.httpdate
225
+ },
226
+ [xml]
227
+ ]
228
+ end
229
+
230
+ def build_openrosa_response_xml(message)
231
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
232
+ xml.OpenRosaResponse(xmlns: "http://openrosa.org/http/response") do
233
+ xml.message message
234
+ end
235
+ end
236
+
237
+ builder.to_xml
238
+ end
239
+
240
+ def error_response(status, message)
241
+ [
242
+ status,
243
+ {
244
+ "Content-Type" => "text/plain",
245
+ "X-OpenRosa-Version" => OPENROSA_VERSION,
246
+ "Date" => Time.now.httpdate
247
+ },
248
+ [message]
249
+ ]
250
+ end
251
+
252
+ def not_found_response(message = "Not Found")
253
+ [
254
+ 404,
255
+ { "Content-Type" => "text/plain" },
256
+ [message]
257
+ ]
258
+ end
259
+
260
+ def authenticated?(env, request)
261
+ # No authentication handler configured = allow all requests
262
+ return true unless @config.authentication_handler
263
+
264
+ # Check if this path should skip authentication
265
+ return true if skip_authentication?(request.path_info)
266
+
267
+ # Call the authentication handler
268
+ result = @config.authentication_handler.call(env)
269
+
270
+ # Store the authenticated user/result in env for later use
271
+ env["openrosa.authenticated_user"] = result if result
272
+
273
+ # Return true if result is truthy (user object, true, etc.)
274
+ !!result
275
+ end
276
+
277
+ def skip_authentication?(path)
278
+ @config.skip_authentication_for.any? do |skip_path|
279
+ if skip_path.is_a?(Regexp)
280
+ path =~ skip_path
281
+ else
282
+ path == normalize_skip_path(skip_path)
283
+ end
284
+ end
285
+ end
286
+
287
+ def normalize_skip_path(path)
288
+ # Ensure skip path includes mount_path if it's a relative path
289
+ return path if path.start_with?(mount_path)
290
+
291
+ "#{mount_path}#{path}"
292
+ end
293
+
294
+ def unauthorized_response
295
+ [
296
+ 401,
297
+ {
298
+ "Content-Type" => "text/plain",
299
+ "WWW-Authenticate" => "Basic realm=\"#{@config.authentication_realm}\"",
300
+ "X-OpenRosa-Version" => OPENROSA_VERSION,
301
+ "Date" => Time.now.httpdate
302
+ },
303
+ ["Unauthorized"]
304
+ ]
305
+ end
306
+
307
+ # Configuration object for the middleware
308
+ class Configuration
309
+ attr_accessor :forms, :mount_path, :base_url, :submission_handler, :max_submission_size,
310
+ :authentication_handler, :skip_authentication_for, :authentication_realm
311
+
312
+ def initialize
313
+ @forms = []
314
+ @mount_path = "/openrosa"
315
+ @base_url = nil
316
+ @submission_handler = nil
317
+ @max_submission_size = 10_485_760 # 10MB default
318
+ @authentication_handler = nil
319
+ @skip_authentication_for = []
320
+ @authentication_realm = "OpenRosa"
321
+ end
322
+
323
+ # Set the submission handler callback
324
+ #
325
+ # @yield [submission] The parsed submission
326
+ # @yieldparam submission [Submission] The submission object
327
+ # @yieldreturn [String, nil] Optional success message
328
+ def on_submission(&block)
329
+ @submission_handler = block
330
+ end
331
+
332
+ # Set the authentication handler callback
333
+ #
334
+ # @yield [env] The Rack environment hash
335
+ # @yieldparam env [Hash] The Rack environment
336
+ # @yieldreturn [Object, true, false, nil] Return truthy to authenticate, falsy to deny
337
+ #
338
+ # Example:
339
+ # config.authenticate do |env|
340
+ # request = Rack::Request.new(env)
341
+ # auth = Rack::Auth::Basic::Request.new(env)
342
+ # if auth.provided? && auth.basic?
343
+ # username, password = auth.credentials
344
+ # User.authenticate(username, password) # Returns user object or nil
345
+ # end
346
+ # end
347
+ def authenticate(&block)
348
+ @authentication_handler = block
349
+ end
350
+ end
351
+ end
352
+ end