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,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Example of how to integrate OpenRosa middleware in a Rails application
4
+ #
5
+ # Add this to your Rails config/application.rb or config/environments/production.rb
6
+
7
+ # Step 1: Define your forms (perhaps in app/forms/)
8
+ class MySurveyForm < OpenRosa::Form
9
+ form_id "my_survey"
10
+ version "1.0.0"
11
+ name "My Survey"
12
+ download_url "https://myapp.com/openrosa/forms/my_survey"
13
+
14
+ input :name, label: "Name", type: :string, required: true
15
+ input :age, label: "Age", type: :int
16
+ select1 :gender, label: "Gender", choices: ["Male", "Female", "Other"]
17
+
18
+ # Option A: Form-specific submission handler
19
+ on_submit do |submission|
20
+ # Handle submissions for this specific form
21
+ Survey.create!(
22
+ instance_id: submission.instance_id,
23
+ name: submission.data["name"],
24
+ age: submission.data["age"],
25
+ gender: submission.data["gender"],
26
+ submitted_at: submission.metadata[:timeEnd]
27
+ )
28
+
29
+ "Thank you for completing the survey!"
30
+ end
31
+ end
32
+
33
+ # Step 2: Add middleware to your Rails application
34
+ # In config/application.rb:
35
+ #
36
+ # module MyApp
37
+ # class Application < Rails::Application
38
+ # # ... other config ...
39
+ #
40
+ # config.middleware.use OpenRosa::Middleware do |config|
41
+ # config.forms = [MySurveyForm]
42
+ # config.mount_path = "/openrosa"
43
+ #
44
+ # # Option B: Global submission handler (for all forms)
45
+ # config.on_submission do |submission|
46
+ # # Generic handler for all form submissions
47
+ # FormSubmission.create!(
48
+ # form_id: submission.form_id,
49
+ # instance_id: submission.instance_id,
50
+ # data: submission.data,
51
+ # metadata: submission.metadata,
52
+ # raw_xml: submission.raw_xml
53
+ # )
54
+ #
55
+ # # Handle file attachments
56
+ # submission.attachments.each do |attachment|
57
+ # # Save to ActiveStorage or file system
58
+ # record = FormSubmission.find_by(instance_id: submission.instance_id)
59
+ # record.attachments.attach(
60
+ # io: StringIO.new(attachment.read),
61
+ # filename: attachment.filename,
62
+ # content_type: attachment.content_type
63
+ # )
64
+ # end
65
+ #
66
+ # "Submission received successfully!"
67
+ # end
68
+ # end
69
+ # end
70
+ # end
71
+
72
+ # Alternative: Use an initializer
73
+ # Create config/initializers/openrosa.rb:
74
+ #
75
+ # Rails.application.config.middleware.use OpenRosa::Middleware do |config|
76
+ # # Load all form classes from app/forms
77
+ # config.forms = Dir[Rails.root.join("app/forms/**/*.rb")].map do |file|
78
+ # require file
79
+ # # Extract class name from file and constantize
80
+ # File.basename(file, ".rb").camelize.constantize
81
+ # end
82
+ #
83
+ # config.mount_path = "/openrosa"
84
+ #
85
+ # # Global submission handler
86
+ # config.on_submission do |submission|
87
+ # # Process submission...
88
+ # Rails.logger.info "Received submission for form: #{submission.form_id}"
89
+ #
90
+ # FormSubmission.create!(
91
+ # form_id: submission.form_id,
92
+ # instance_id: submission.instance_id,
93
+ # payload: submission.data
94
+ # )
95
+ # end
96
+ # end
97
+
98
+ # Step 3: Endpoints will be available at:
99
+ # GET /openrosa/formList - List all forms
100
+ # GET /openrosa/forms/:id - Download specific form XForm XML
101
+ # HEAD /openrosa/submission - Pre-flight check for submissions
102
+ # POST /openrosa/submission - Receive form submissions
103
+ #
104
+ # These endpoints will properly return XML with OpenRosa headers:
105
+ # - Content-Type: text/xml; charset=utf-8
106
+ # - X-OpenRosa-Version: 1.0
107
+ # - Date: [current date in HTTP format]
108
+
109
+ # Step 4: (Optional) Add authentication
110
+ # You can add authentication by placing another middleware before OpenRosa:
111
+ #
112
+ # Rails.application.config.middleware.insert_before(
113
+ # OpenRosa::Middleware,
114
+ # MyAuthMiddleware
115
+ # )
116
+ #
117
+ # Or use Rails controllers instead for more control:
118
+ # Create app/controllers/openrosa_controller.rb:
119
+ #
120
+ # class OpenrosaController < ApplicationController
121
+ # before_action :authenticate_user!
122
+ #
123
+ # def form_list
124
+ # form_id = params[:formID]
125
+ # verbose = params[:verbose] == "true"
126
+ #
127
+ # forms = [MySurveyForm.new]
128
+ # form_list = OpenRosa::FormList.new(forms, form_id: form_id, verbose: verbose)
129
+ #
130
+ # render xml: form_list.to_xml, content_type: "text/xml; charset=utf-8"
131
+ # response.headers["X-OpenRosa-Version"] = "1.0"
132
+ # end
133
+ #
134
+ # def form_download
135
+ # form_class = find_form_class(params[:id])
136
+ # return head :not_found unless form_class
137
+ #
138
+ # form = form_class.new
139
+ # render xml: form.to_xml, content_type: "text/xml; charset=utf-8"
140
+ # response.headers["X-OpenRosa-Version"] = "1.0"
141
+ # end
142
+ #
143
+ # private
144
+ #
145
+ # def find_form_class(form_id)
146
+ # [MySurveyForm].find { |fc| fc.new.form_id == form_id }
147
+ # end
148
+ # end
149
+ #
150
+ # And in config/routes.rb:
151
+ # get "/openrosa/formList", to: "openrosa#form_list"
152
+ # get "/openrosa/forms/:id", to: "openrosa#form_download"
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ # Helper class for evaluating blocks in group/repeat DSL
5
+ # This allows for clean nested field definitions within groups and repeats
6
+ class FieldContext
7
+ def initialize(fields_array)
8
+ @fields = fields_array
9
+ end
10
+
11
+ def input(name, options = {})
12
+ @fields << Fields::Input.new(name, options)
13
+ end
14
+
15
+ def select1(name, options = {})
16
+ @fields << Fields::Select1.new(name, options)
17
+ end
18
+
19
+ def select(name, options = {})
20
+ @fields << Fields::Select.new(name, options)
21
+ end
22
+
23
+ def boolean(name, options = {})
24
+ @fields << Fields::Boolean.new(name, options)
25
+ end
26
+
27
+ def upload(name, options = {})
28
+ @fields << Fields::Upload.new(name, options)
29
+ end
30
+
31
+ def range(name, options = {})
32
+ @fields << Fields::Range.new(name, options)
33
+ end
34
+
35
+ def trigger(name, options = {})
36
+ @fields << Fields::Trigger.new(name, options)
37
+ end
38
+
39
+ def group(name, options = {}, &)
40
+ nested_fields = []
41
+ if block_given?
42
+ context = FieldContext.new(nested_fields)
43
+ context.instance_eval(&)
44
+ end
45
+
46
+ @fields << Fields::Group.new(name, options.merge(fields: nested_fields))
47
+ end
48
+
49
+ def repeat(name, options = {}, &)
50
+ nested_fields = []
51
+ if block_given?
52
+ context = FieldContext.new(nested_fields)
53
+ context.instance_eval(&)
54
+ end
55
+
56
+ @fields << Fields::Repeat.new(name, options.merge(fields: nested_fields))
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ module Fields
5
+ # Base class for all form field types
6
+ class Base
7
+ attr_reader :name, :label, :hint, :required, :default
8
+
9
+ def initialize(name, options = {})
10
+ @name = name
11
+ @label = options[:label]
12
+ @hint = options[:hint]
13
+ @required = options.fetch(:required, false)
14
+ @default = options[:default]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ module Fields
5
+ # Boolean field for true/false values
6
+ # Maps to XForms select1 element with true/false options
7
+ # Data type: boolean (stored as "true" or "false" strings in XForms)
8
+ class Boolean < Base
9
+ attr_reader :appearance
10
+
11
+ def initialize(name, options = {})
12
+ super
13
+ @appearance = options[:appearance]
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ module Fields
5
+ # Group field for logical grouping of fields
6
+ # Maps to XForms <group> element
7
+ # Can contain nested fields and supports conditional display
8
+ class Group < Base
9
+ attr_reader :fields, :relevant, :appearance
10
+
11
+ def initialize(name, options = {})
12
+ super
13
+ @fields = options.fetch(:fields, [])
14
+ @relevant = options[:relevant]
15
+ @appearance = options[:appearance]
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ module Fields
5
+ # Input field for text, numbers, dates, and other basic input types
6
+ class Input < Base
7
+ attr_reader :type, :constraint, :constraint_message
8
+
9
+ VALID_TYPES = %i[
10
+ string int decimal date time dateTime
11
+ geopoint geotrace geoshape barcode binary intent
12
+ ].freeze
13
+
14
+ def initialize(name, options = {})
15
+ super
16
+ @type = options.fetch(:type, :string)
17
+ @constraint = options[:constraint]
18
+ @constraint_message = options[:constraint_message]
19
+
20
+ validate!
21
+ end
22
+
23
+ private
24
+
25
+ def validate!
26
+ return if VALID_TYPES.include?(@type)
27
+
28
+ raise ArgumentError, "type must be one of: #{VALID_TYPES.join(", ")}"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ module Fields
5
+ # Range field for numeric sliders/pickers
6
+ # Maps to XForms <range> element
7
+ # Data type: int or decimal
8
+ class Range < Base
9
+ attr_reader :start, :end, :step, :type
10
+
11
+ VALID_TYPES = %i[int decimal].freeze
12
+
13
+ def initialize(name, options = {})
14
+ super
15
+ @start = options[:start]
16
+ @end = options[:end]
17
+ @step = options.fetch(:step, 1)
18
+ @type = options.fetch(:type, :int)
19
+
20
+ validate!
21
+ end
22
+
23
+ private
24
+
25
+ def validate!
26
+ raise ArgumentError, "start is required for Range field" if @start.nil?
27
+ raise ArgumentError, "end is required for Range field" if @end.nil?
28
+ raise ArgumentError, "start must be less than end" if @start >= @end
29
+ raise ArgumentError, "step must be greater than 0" if @step <= 0
30
+ raise ArgumentError, "type must be :int or :decimal" unless VALID_TYPES.include?(@type)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ module Fields
5
+ # Repeat field for repeating sections
6
+ # Maps to XForms <repeat> element
7
+ # All fields within repeat can occur multiple times
8
+ class Repeat < Base
9
+ attr_reader :fields, :count, :appearance, :relevant
10
+
11
+ def initialize(name, options = {})
12
+ super
13
+ @fields = options.fetch(:fields, [])
14
+ @count = options[:count]
15
+ @appearance = options[:appearance]
16
+ @relevant = options[:relevant]
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ module Fields
5
+ # Multiple select field (checkboxes)
6
+ # Maps to XForms <select> element
7
+ # Values are stored as space-separated strings in submissions
8
+ class Select < Base
9
+ attr_reader :choices, :appearance
10
+
11
+ def initialize(name, options = {})
12
+ super
13
+ @choices = options[:choices]
14
+ @appearance = options[:appearance]
15
+
16
+ validate!
17
+ end
18
+
19
+ private
20
+
21
+ def validate!
22
+ raise ArgumentError, "choices is required for Select field" if @choices.nil?
23
+ raise ArgumentError, "choices must be an Array or Hash" unless @choices.is_a?(Array) || @choices.is_a?(Hash)
24
+ raise ArgumentError, "choices cannot be empty" if @choices.empty?
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ module Fields
5
+ # Single select field (radio buttons or dropdown)
6
+ # Maps to XForms <select1> element
7
+ class Select1 < Base
8
+ attr_reader :choices, :appearance
9
+
10
+ def initialize(name, options = {})
11
+ super
12
+ @choices = options[:choices]
13
+ @appearance = options[:appearance]
14
+
15
+ validate!
16
+ end
17
+
18
+ private
19
+
20
+ def validate!
21
+ raise ArgumentError, "choices is required for Select1 field" if @choices.nil?
22
+ raise ArgumentError, "choices must be an Array or Hash" unless @choices.is_a?(Array) || @choices.is_a?(Hash)
23
+ raise ArgumentError, "choices cannot be empty" if @choices.empty?
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ module Fields
5
+ # Trigger field for confirmation buttons/checkboxes
6
+ # Maps to XForms <trigger> element
7
+ # Adds "OK" value when confirmed
8
+ class Trigger < Base
9
+ attr_reader :appearance
10
+
11
+ def initialize(name, options = {})
12
+ super
13
+ @appearance = options[:appearance]
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ module Fields
5
+ # Upload field for file/media uploads
6
+ # Maps to XForms <upload> element
7
+ # Data type: binary
8
+ class Upload < Base
9
+ attr_reader :mediatype, :max_pixels
10
+
11
+ def initialize(name, options = {})
12
+ super
13
+ @mediatype = options[:mediatype]
14
+ @max_pixels = options[:max_pixels]
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ # Base class for defining OpenRosa forms with a clean DSL
5
+ class Form
6
+ class << self
7
+ include FormDSL
8
+
9
+ def form_id(id = nil)
10
+ if id
11
+ @form_id = id
12
+ else
13
+ @form_id
14
+ end
15
+ end
16
+
17
+ def version(ver = nil)
18
+ if ver
19
+ @version = ver
20
+ else
21
+ @version
22
+ end
23
+ end
24
+
25
+ def name(form_name = nil)
26
+ if form_name
27
+ @name = form_name
28
+ else
29
+ @name
30
+ end
31
+ end
32
+
33
+ def description_text(text = nil)
34
+ if text
35
+ @description_text = text
36
+ else
37
+ @description_text
38
+ end
39
+ end
40
+
41
+ def description_url(url = nil)
42
+ if url
43
+ @description_url = url
44
+ else
45
+ @description_url
46
+ end
47
+ end
48
+
49
+ def download_url(url = nil)
50
+ if url
51
+ @download_url = url
52
+ else
53
+ @download_url
54
+ end
55
+ end
56
+
57
+ def manifest_url(url = nil)
58
+ if url
59
+ @manifest_url = url
60
+ else
61
+ @manifest_url
62
+ end
63
+ end
64
+
65
+ # Calculates MD5 hash of the form's XForm XML content
66
+ # Returns format: "md5:abc123..."
67
+ def form_hash
68
+ require "digest"
69
+ xml = to_xml
70
+ hash = Digest::MD5.hexdigest(xml)
71
+ "md5:#{hash}"
72
+ end
73
+
74
+ # Generates XForm XML from the form definition
75
+ # Returns the complete XForm XML as a string
76
+ def to_xml
77
+ XForm.new(self).to_xml
78
+ end
79
+
80
+ # Define a handler for form submissions
81
+ #
82
+ # @yield [submission] The parsed submission
83
+ # @yieldparam submission [Submission] The submission object
84
+ # @yieldreturn [String, nil] Optional success message
85
+ #
86
+ # Example:
87
+ # class MyForm < OpenRosa::Form
88
+ # on_submit do |submission|
89
+ # MyModel.create!(data: submission.data)
90
+ # "Thanks for submitting!"
91
+ # end
92
+ # end
93
+ def on_submit(&block)
94
+ @submission_handler = block
95
+ end
96
+
97
+ # Handle a submission (called by middleware)
98
+ #
99
+ # @param submission [Submission] The parsed submission
100
+ # @return [String, nil] Success message or nil
101
+ def handle_submission(submission)
102
+ @submission_handler&.call(submission)
103
+ end
104
+ end
105
+
106
+ def form_id
107
+ self.class.form_id
108
+ end
109
+
110
+ def version
111
+ self.class.version
112
+ end
113
+
114
+ def name
115
+ self.class.name
116
+ end
117
+
118
+ def description_text
119
+ self.class.description_text
120
+ end
121
+
122
+ def description_url
123
+ self.class.description_url
124
+ end
125
+
126
+ def download_url
127
+ self.class.download_url
128
+ end
129
+
130
+ def manifest_url
131
+ self.class.manifest_url
132
+ end
133
+
134
+ def form_hash
135
+ self.class.form_hash
136
+ end
137
+
138
+ def fields
139
+ self.class.fields
140
+ end
141
+
142
+ def to_xml
143
+ self.class.to_xml
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenRosa
4
+ # DSL methods for defining form fields
5
+ module FormDSL
6
+ # Returns array of all fields defined in this form
7
+ def fields
8
+ @fields ||= []
9
+ end
10
+
11
+ # DSL method to add an input field
12
+ def input(name, options = {})
13
+ fields << Fields::Input.new(name, options)
14
+ end
15
+
16
+ # DSL method to add a select1 field (single select)
17
+ def select1(name, options = {})
18
+ fields << Fields::Select1.new(name, options)
19
+ end
20
+
21
+ # DSL method to add a select field (multiple select)
22
+ def select(name, options = {})
23
+ fields << Fields::Select.new(name, options)
24
+ end
25
+
26
+ # DSL method to add a boolean field
27
+ def boolean(name, options = {})
28
+ fields << Fields::Boolean.new(name, options)
29
+ end
30
+
31
+ # DSL method to add an upload field
32
+ def upload(name, options = {})
33
+ fields << Fields::Upload.new(name, options)
34
+ end
35
+
36
+ # DSL method to add a range field
37
+ def range(name, options = {})
38
+ fields << Fields::Range.new(name, options)
39
+ end
40
+
41
+ # DSL method to add a trigger field
42
+ def trigger(name, options = {})
43
+ fields << Fields::Trigger.new(name, options)
44
+ end
45
+
46
+ # DSL method to add a group field with nested fields
47
+ def group(name, options = {}, &)
48
+ # Create a temporary context to collect nested fields
49
+ nested_fields = []
50
+ if block_given?
51
+ context = FieldContext.new(nested_fields)
52
+ context.instance_eval(&)
53
+ end
54
+
55
+ fields << Fields::Group.new(name, options.merge(fields: nested_fields))
56
+ end
57
+
58
+ # DSL method to add a repeat field with nested fields
59
+ def repeat(name, options = {}, &)
60
+ # Create a temporary context to collect nested fields
61
+ nested_fields = []
62
+ if block_given?
63
+ context = FieldContext.new(nested_fields)
64
+ context.instance_eval(&)
65
+ end
66
+
67
+ fields << Fields::Repeat.new(name, options.merge(fields: nested_fields))
68
+ end
69
+ end
70
+ end