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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4971029b8b1f9034f894d18db290b204f7e4a8a62d7955dd9ecc9bf23a2669fc
4
+ data.tar.gz: 3dc882cc5e3b522d920c69c0b18d82fb6c8d4a2b0e44ecd41fc18155487f1efc
5
+ SHA512:
6
+ metadata.gz: b90bcba3f90852d450de4569d26a3e3ffff4b4e67e7a879ad4115f638ee3871033d3f92e0dd18ad810b33255b54e9ab8f4142c34e24a75c5dfcb42dd6dfc3dc3
7
+ data.tar.gz: bb75af6367be14823f22259ddaff589f48b6a3f33794c0874633182741080cd2502d02bb87242520f71ca3486a7e8ddf28b8aa11f12c811ee706c32d454c76b6
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2025-12-26
11
+
12
+ Initial release.
13
+
14
+ ### Added
15
+
16
+ - Form DSL for defining OpenRosa forms with metadata (`form_id`, `version`, `name`, `description_text`, `download_url`, `manifest_url`)
17
+ - Field types: `input`, `select1`, `select`, `boolean`, `range`, `upload`, `trigger`, `group`, `repeat`
18
+ - XForm XML generation from form definitions
19
+ - Form List API for listing available forms
20
+ - Manifest support for media files
21
+ - Submission parsing with support for XML data and file attachments
22
+ - Rack middleware for serving OpenRosa endpoints (`/formList`, `/forms/:id`, `/submission`)
23
+ - Per-form and global submission handlers
24
+ - Authentication hook support
25
+ - Ruby 3.2+ compatibility
data/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ Before finishing a task, run `rake` to make sure that tests and Rubocop is happy. Fix as many Rubocop issues as you can. If you can't, use the comment syntax to disable the rule.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Alex Watt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,285 @@
1
+ # OpenRosa Ruby
2
+
3
+ A Ruby gem for building [OpenRosa](https://docs.getodk.org/openrosa/) compliant form servers. Define forms with a clean DSL, serve them via Rack middleware, and handle submissions from mobile data collection apps like ODK Collect.
4
+
5
+ ## Installation
6
+
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "open_rosa"
11
+ ```
12
+
13
+ Or install directly:
14
+
15
+ ```bash
16
+ gem install open_rosa
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ### 1. Define a Form
22
+
23
+ ```ruby
24
+ class SurveyForm < OpenRosa::Form
25
+ form_id "my_survey"
26
+ version "1.0.0"
27
+ name "Customer Survey"
28
+
29
+ input :name, label: "Your Name", type: :string, required: true
30
+ input :email, label: "Email", type: :string
31
+ input :age, label: "Age", type: :int
32
+
33
+ select1 :satisfaction,
34
+ label: "How satisfied are you?",
35
+ choices: {
36
+ "very_satisfied" => "Very Satisfied",
37
+ "satisfied" => "Satisfied",
38
+ "neutral" => "Neutral",
39
+ "dissatisfied" => "Dissatisfied"
40
+ },
41
+ required: true
42
+
43
+ boolean :would_recommend, label: "Would you recommend us?"
44
+
45
+ input :comments, label: "Additional Comments", type: :string
46
+ end
47
+ ```
48
+
49
+ ### 2. Mount the Middleware
50
+
51
+ ```ruby
52
+ # config.ru (Rack) or config/application.rb (Rails)
53
+ use OpenRosa::Middleware do |config|
54
+ config.base_url = "https://example.com"
55
+ config.mount_path = "/openrosa"
56
+ config.forms = [SurveyForm]
57
+
58
+ config.on_submission do |submission|
59
+ puts "Received: #{submission.form_id}"
60
+ puts "Data: #{submission.data}"
61
+ "Thank you for your submission!"
62
+ end
63
+ end
64
+ ```
65
+
66
+ ### 3. Available Endpoints
67
+
68
+ The middleware provides these OpenRosa-compliant endpoints:
69
+
70
+ | Method | Path | Description |
71
+ |--------|------|-------------|
72
+ | GET | `/openrosa/formList` | List available forms |
73
+ | GET | `/openrosa/forms/:id` | Download form XForm XML |
74
+ | HEAD | `/openrosa/submission` | Pre-flight check |
75
+ | POST | `/openrosa/submission` | Submit form data |
76
+
77
+ ## Field Types
78
+
79
+ ### input
80
+
81
+ Text, numeric, and date inputs:
82
+
83
+ ```ruby
84
+ input :name, label: "Name", type: :string, required: true
85
+ input :age, label: "Age", type: :int
86
+ input :temperature, label: "Temperature", type: :decimal
87
+ input :birthdate, label: "Birth Date", type: :date
88
+ input :appointment, label: "Appointment", type: :dateTime
89
+ input :start_time, label: "Start Time", type: :time
90
+ ```
91
+
92
+ ### select1
93
+
94
+ Single-choice selection:
95
+
96
+ ```ruby
97
+ select1 :color,
98
+ label: "Favorite Color",
99
+ choices: { "r" => "Red", "g" => "Green", "b" => "Blue" },
100
+ appearance: "minimal"
101
+ ```
102
+
103
+ ### select
104
+
105
+ Multiple-choice selection:
106
+
107
+ ```ruby
108
+ select :topics,
109
+ label: "Topics of Interest",
110
+ choices: ["Technology", "Science", "Art", "Music"]
111
+ ```
112
+
113
+ ### boolean
114
+
115
+ Yes/no questions:
116
+
117
+ ```ruby
118
+ boolean :agree, label: "I agree to the terms", appearance: "checkbox"
119
+ ```
120
+
121
+ ### range
122
+
123
+ Numeric range slider:
124
+
125
+ ```ruby
126
+ range :rating,
127
+ label: "Rate 1-10",
128
+ start: 1,
129
+ end: 10,
130
+ step: 1,
131
+ type: :int
132
+ ```
133
+
134
+ ### upload
135
+
136
+ File uploads:
137
+
138
+ ```ruby
139
+ upload :photo, label: "Take a photo", mediatype: "image/*"
140
+ upload :signature, label: "Signature", mediatype: "image/*", appearance: "signature"
141
+ upload :audio, label: "Record audio", mediatype: "audio/*"
142
+ ```
143
+
144
+ ### trigger
145
+
146
+ Action button (acknowledge/confirm):
147
+
148
+ ```ruby
149
+ trigger :acknowledge, label: "I have read and understood", appearance: "acknowledge"
150
+ ```
151
+
152
+ ### group
153
+
154
+ Logical grouping of fields:
155
+
156
+ ```ruby
157
+ group :contact_info, label: "Contact Information" do
158
+ input :phone, label: "Phone", type: :string
159
+ input :address, label: "Address", type: :string
160
+ end
161
+ ```
162
+
163
+ ### repeat
164
+
165
+ Repeatable field groups:
166
+
167
+ ```ruby
168
+ repeat :household_members, label: "Household Members" do
169
+ input :member_name, label: "Name", type: :string
170
+ input :member_age, label: "Age", type: :int
171
+ end
172
+ ```
173
+
174
+ ## Form Metadata
175
+
176
+ ```ruby
177
+ class MyForm < OpenRosa::Form
178
+ form_id "unique_form_id" # Required: unique identifier
179
+ version "1.0.0" # Form version
180
+ name "Human Readable Name" # Display name
181
+ description_text "Form description"
182
+ download_url "https://..." # Optional: auto-generated from base_url if not set
183
+ manifest_url "https://..." # URL for media manifest (if needed)
184
+ end
185
+ ```
186
+
187
+ ## Handling Submissions
188
+
189
+ ### Global Handler (Middleware)
190
+
191
+ ```ruby
192
+ use OpenRosa::Middleware do |config|
193
+ config.forms = [MyForm]
194
+
195
+ config.on_submission do |submission|
196
+ # submission.form_id - Form identifier
197
+ # submission.instance_id - Unique submission ID
198
+ # submission.data - Hash of field values
199
+ # submission.metadata - Submission metadata (timeStart, timeEnd, etc.)
200
+ # submission.raw_xml - Original XML string
201
+ # submission.attachments - Array of uploaded files
202
+
203
+ MyModel.create!(
204
+ form_id: submission.form_id,
205
+ data: submission.data
206
+ )
207
+
208
+ submission.attachments.each do |file|
209
+ # file.filename, file.content_type, file.read, file.size
210
+ save_file(file)
211
+ end
212
+
213
+ "Submission received!" # Optional success message
214
+ end
215
+ end
216
+ ```
217
+
218
+ ### Per-Form Handler
219
+
220
+ ```ruby
221
+ class MyForm < OpenRosa::Form
222
+ form_id "my_form"
223
+
224
+ input :name, label: "Name", type: :string
225
+
226
+ on_submit do |submission|
227
+ # Handle submissions for this specific form
228
+ Record.create!(name: submission.data["name"])
229
+ "Thank you!"
230
+ end
231
+ end
232
+ ```
233
+
234
+ ## Rails Integration
235
+
236
+ ```ruby
237
+ # config/application.rb
238
+ module MyApp
239
+ class Application < Rails::Application
240
+ config.middleware.use OpenRosa::Middleware do |config|
241
+ config.forms = [SurveyForm, FeedbackForm]
242
+ config.mount_path = "/openrosa"
243
+
244
+ config.on_submission do |submission|
245
+ FormSubmission.create!(
246
+ form_id: submission.form_id,
247
+ instance_id: submission.instance_id,
248
+ data: submission.data
249
+ )
250
+ end
251
+ end
252
+ end
253
+ end
254
+ ```
255
+
256
+ ## Generating XForm XML
257
+
258
+ You can generate the XForm XML directly:
259
+
260
+ ```ruby
261
+ xml = SurveyForm.to_xml
262
+ # => Complete XForm XML string
263
+
264
+ hash = SurveyForm.form_hash
265
+ # => "md5:abc123..." (for change detection)
266
+ ```
267
+
268
+ ## Development
269
+
270
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake` to run the tests and linter.
271
+
272
+ ```bash
273
+ bin/setup
274
+ rake
275
+ ```
276
+
277
+ Use `bin/console` for an interactive prompt to experiment.
278
+
279
+ ## Contributing
280
+
281
+ Bug reports and pull requests are welcome on GitHub at https://github.com/alexcwatt/openrosa-ruby.
282
+
283
+ ## License
284
+
285
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Authentication examples for OpenRosa middleware
4
+
5
+ require "open_rosa"
6
+
7
+ # rubocop:disable Metrics/MethodLength
8
+
9
+ # Example 1: HTTP Basic Authentication (most common for ODK clients)
10
+ class BasicAuthExample
11
+ def self.middleware
12
+ OpenRosa::Middleware.new do |config|
13
+ config.forms = [] # Add your forms here
14
+
15
+ config.authenticate do |env|
16
+ auth = Rack::Auth::Basic::Request.new(env)
17
+ if auth.provided? && auth.basic? && auth.credentials
18
+ username, password = auth.credentials
19
+ # Replace with your authentication logic:
20
+ # User.find_by(username: username)&.authenticate(password)
21
+ { username: username } if username == "admin" && password == "secret"
22
+ end
23
+ end
24
+
25
+ # Optional: Customize the realm shown in browser login prompt
26
+ config.authentication_realm = "ODK Collect"
27
+ end
28
+ end
29
+ end
30
+
31
+ # Example 2: Bearer Token Authentication (e.g. for API clients)
32
+ class TokenAuthExample
33
+ def self.middleware
34
+ OpenRosa::Middleware.new do |config|
35
+ config.forms = [] # Add your forms here
36
+
37
+ config.authenticate do |env|
38
+ token = env["HTTP_AUTHORIZATION"]&.sub(/^Bearer /, "")
39
+ # Replace with your token validation:
40
+ # ApiKey.find_by(token: token, active: true)&.user
41
+ { token: token } if token == "valid-token-12345"
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ # Example 3: Selective Authentication (public formList, protected submissions)
48
+ class SelectiveAuthExample
49
+ def self.middleware
50
+ OpenRosa::Middleware.new do |config|
51
+ config.forms = [] # Add your forms here
52
+
53
+ config.authenticate do |env|
54
+ auth = Rack::Auth::Basic::Request.new(env)
55
+ if auth.provided? && auth.basic? && auth.credentials
56
+ username, password = auth.credentials
57
+ username == "admin" && password == "secret"
58
+ end
59
+ end
60
+
61
+ # Skip auth for these paths (supports strings and regex)
62
+ config.skip_authentication_for = ["/formList", %r{/forms/}]
63
+ end
64
+ end
65
+ end
66
+
67
+ # Usage in config.ru:
68
+ # require_relative "examples/authentication"
69
+ # run BasicAuthExample.middleware
70
+ #
71
+ # In Rails (config/application.rb):
72
+ # config.middleware.use OpenRosa::Middleware do |config|
73
+ # config.authenticate do |env|
74
+ # # Your auth logic here
75
+ # end
76
+ # end
77
+ #
78
+ # The authenticated user is stored in env["openrosa.authenticated_user"]
79
+
80
+ # rubocop:enable Metrics/MethodLength
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example Rack application demonstrating OpenRosa middleware usage
4
+ #
5
+ # Run this with: rackup examples/rack_app.rb
6
+ # Then access:
7
+ # - http://localhost:9292/openrosa/formList
8
+ # - http://localhost:9292/openrosa/forms/customer_survey
9
+
10
+ require_relative "../lib/open_rosa"
11
+
12
+ # Define a sample customer survey form
13
+ class CustomerSurvey < OpenRosa::Form
14
+ form_id "customer_survey"
15
+ version "1.0.0"
16
+ name "Customer Satisfaction Survey"
17
+ description_text "Please rate your experience with our service"
18
+ download_url "http://localhost:9292/openrosa/forms/customer_survey"
19
+
20
+ # Customer information
21
+ input :customer_name,
22
+ label: "Your Name",
23
+ type: :string,
24
+ required: true
25
+
26
+ input :customer_email,
27
+ label: "Email Address",
28
+ type: :string
29
+
30
+ # Satisfaction rating
31
+ select1 :satisfaction,
32
+ label: "Overall Satisfaction",
33
+ choices: {
34
+ "very_satisfied" => "Very Satisfied",
35
+ "satisfied" => "Satisfied",
36
+ "neutral" => "Neutral",
37
+ "dissatisfied" => "Dissatisfied",
38
+ "very_dissatisfied" => "Very Dissatisfied"
39
+ },
40
+ required: true,
41
+ appearance: "minimal"
42
+
43
+ # Rating scale
44
+ range :service_rating,
45
+ label: "Rate our service (1-10)",
46
+ start: 1,
47
+ end: 10,
48
+ step: 1,
49
+ type: :int
50
+
51
+ # Comments
52
+ input :comments,
53
+ label: "Additional Comments",
54
+ type: :string
55
+
56
+ # Optional photo
57
+ upload :photo,
58
+ label: "Upload a photo (optional)",
59
+ mediatype: "image/*"
60
+
61
+ # Visit date
62
+ input :visit_date,
63
+ label: "Date of Visit",
64
+ type: :date
65
+
66
+ # Would recommend
67
+ boolean :would_recommend,
68
+ label: "Would you recommend us to a friend?",
69
+ appearance: "checkbox"
70
+ end
71
+
72
+ # Define an employee feedback form
73
+ class EmployeeFeedback < OpenRosa::Form
74
+ form_id "employee_feedback"
75
+ version "2.0.0"
76
+ name "Employee Feedback Form"
77
+ description_text "Quarterly employee feedback survey"
78
+ download_url "http://localhost:9292/openrosa/forms/employee_feedback"
79
+
80
+ group :personal_info, label: "Personal Information" do
81
+ input :employee_id,
82
+ label: "Employee ID",
83
+ type: :string,
84
+ required: true
85
+
86
+ input :department,
87
+ label: "Department",
88
+ type: :string
89
+ end
90
+
91
+ group :feedback, label: "Feedback" do
92
+ select :areas_improvement,
93
+ label: "Areas for Improvement",
94
+ choices: [
95
+ "Communication",
96
+ "Work-Life Balance",
97
+ "Career Development",
98
+ "Management",
99
+ "Compensation"
100
+ ]
101
+
102
+ input :suggestions,
103
+ label: "Your Suggestions",
104
+ type: :string
105
+ end
106
+ end
107
+
108
+ # Create a simple Rack app that responds to non-OpenRosa paths
109
+ app = lambda do |env|
110
+ request = Rack::Request.new(env)
111
+
112
+ if request.path == "/"
113
+ [
114
+ 200,
115
+ { "Content-Type" => "text/html" },
116
+ [<<~HTML]
117
+ <!DOCTYPE html>
118
+ <html>
119
+ <head><title>OpenRosa Example Server</title></head>
120
+ <body>
121
+ <h1>OpenRosa Example Server</h1>
122
+ <h2>Available Endpoints:</h2>
123
+ <ul>
124
+ <li><a href="/openrosa/formList">GET /openrosa/formList</a> - List all forms</li>
125
+ <li><a href="/openrosa/formList?formID=customer_survey">GET /openrosa/formList?formID=customer_survey</a> - Filter by form ID</li>
126
+ <li><a href="/openrosa/formList?verbose=true">GET /openrosa/formList?verbose=true</a> - Verbose mode</li>
127
+ <li><a href="/openrosa/forms/customer_survey">GET /openrosa/forms/customer_survey</a> - Download Customer Survey form</li>
128
+ <li><a href="/openrosa/forms/employee_feedback">GET /openrosa/forms/employee_feedback</a> - Download Employee Feedback form</li>
129
+ <li>HEAD /openrosa/submission - Pre-flight check for submissions</li>
130
+ <li>POST /openrosa/submission - Submit form data (multipart/form-data)</li>
131
+ </ul>
132
+ <h2>Available Forms:</h2>
133
+ <ul>
134
+ <li><strong>customer_survey</strong> - Customer Satisfaction Survey (v1.0.0)</li>
135
+ <li><strong>employee_feedback</strong> - Employee Feedback Form (v2.0.0)</li>
136
+ </ul>
137
+ </body>
138
+ </html>
139
+ HTML
140
+ ]
141
+ else
142
+ [404, { "Content-Type" => "text/plain" }, ["Not Found"]]
143
+ end
144
+ end
145
+
146
+ # Wrap the app with OpenRosa middleware
147
+ use OpenRosa::Middleware do |config|
148
+ config.forms = [CustomerSurvey, EmployeeFeedback]
149
+ config.mount_path = "/openrosa"
150
+
151
+ # Handle form submissions
152
+ config.on_submission do |submission|
153
+ # Print submission details to console
154
+ puts "\n=== Form Submission Received ==="
155
+ puts "Form ID: #{submission.form_id}"
156
+ puts "Instance ID: #{submission.instance_id}"
157
+ puts "Submitted at: #{submission.metadata[:timeEnd]}"
158
+ puts "Data:"
159
+ submission.data.each do |key, value|
160
+ puts " #{key}: #{value}"
161
+ end
162
+
163
+ if submission.attachments.any?
164
+ puts "Attachments:"
165
+ submission.attachments.each do |att|
166
+ puts " - #{att.filename} (#{att.content_type}, #{att.size} bytes)"
167
+ # In a real app, you'd save these files:
168
+ # File.write("uploads/#{att.filename}", att.read)
169
+ end
170
+ end
171
+ puts "================================\n"
172
+
173
+ # Return a custom success message
174
+ "Thank you! Your #{submission.form_id} submission has been received."
175
+ end
176
+ end
177
+
178
+ run app