archipelago-rails 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/Appraisals +16 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +79 -0
  5. data/Rakefile +17 -0
  6. data/app/controllers/archipelago/application_controller.rb +10 -0
  7. data/app/controllers/archipelago/islands_controller.rb +158 -0
  8. data/config/database.yml +5 -0
  9. data/config/routes.rb +6 -0
  10. data/lib/archipelago/action.rb +121 -0
  11. data/lib/archipelago/broadcasts.rb +17 -0
  12. data/lib/archipelago/channel.rb +36 -0
  13. data/lib/archipelago/configuration.rb +23 -0
  14. data/lib/archipelago/context.rb +14 -0
  15. data/lib/archipelago/engine.rb +21 -0
  16. data/lib/archipelago/params_dsl.rb +143 -0
  17. data/lib/archipelago/registry.rb +25 -0
  18. data/lib/archipelago/resolver.rb +54 -0
  19. data/lib/archipelago/response.rb +35 -0
  20. data/lib/archipelago/security/origin_validator.rb +32 -0
  21. data/lib/archipelago/security/redirect_validator.rb +30 -0
  22. data/lib/archipelago/test_helpers.rb +31 -0
  23. data/lib/archipelago/view_helper.rb +31 -0
  24. data/lib/archipelago-rails.rb +3 -0
  25. data/lib/archipelago.rb +71 -0
  26. data/lib/generators/archipelago/install/install_generator.rb +43 -0
  27. data/lib/generators/archipelago/install/react/react_generator.rb +380 -0
  28. data/lib/generators/archipelago/install/react/templates/entry.js.tt +13 -0
  29. data/lib/generators/archipelago/install/react/templates/generate_registry.mjs.tt +96 -0
  30. data/lib/generators/archipelago/install/react_generator.rb +3 -0
  31. data/lib/generators/archipelago/install_generator.rb +3 -0
  32. data/lib/generators/archipelago/island/island_generator.rb +44 -0
  33. data/lib/generators/archipelago/island/templates/action.rb.tt +11 -0
  34. data/lib/generators/archipelago/island/templates/component.tsx.tt +14 -0
  35. data/lib/generators/archipelago/island_generator.rb +3 -0
  36. data/test/archipelago/action_test.rb +136 -0
  37. data/test/archipelago/broadcasts_test.rb +29 -0
  38. data/test/archipelago/channel_test.rb +15 -0
  39. data/test/archipelago/notifications_test.rb +60 -0
  40. data/test/archipelago/origin_validator_test.rb +36 -0
  41. data/test/archipelago/params_dsl_test.rb +51 -0
  42. data/test/archipelago/redirect_validator_test.rb +28 -0
  43. data/test/archipelago/resolver_test.rb +80 -0
  44. data/test/archipelago/response_test.rb +30 -0
  45. data/test/archipelago/view_helper_test.rb +32 -0
  46. data/test/controllers/islands_controller_test.rb +115 -0
  47. data/test/generators/install_generator_test.rb +26 -0
  48. data/test/generators/island_generator_test.rb +20 -0
  49. data/test/generators/react_install_generator_test.rb +67 -0
  50. data/test/test_helper.rb +35 -0
  51. metadata +180 -0
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module Archipelago
7
+ module ParamsDSL
8
+ MISSING = Object.new.freeze
9
+
10
+ ParamDefinition = Struct.new(
11
+ :name,
12
+ :type,
13
+ :required,
14
+ :default,
15
+ :strip,
16
+ :downcase,
17
+ :upcase,
18
+ keyword_init: true
19
+ )
20
+
21
+ module ClassMethods
22
+ def param_definitions
23
+ @param_definitions ||= {}
24
+ end
25
+
26
+ def param(name, type, required: false, default: MISSING, strip: false, downcase: false, upcase: false)
27
+ symbol_name = name.to_sym
28
+
29
+ param_definitions[symbol_name] = ParamDefinition.new(
30
+ name: symbol_name,
31
+ type: type,
32
+ required: required,
33
+ default: default,
34
+ strip: strip,
35
+ downcase: downcase,
36
+ upcase: upcase
37
+ )
38
+
39
+ define_method(symbol_name) do
40
+ @coerced_params[symbol_name]
41
+ end
42
+ end
43
+ end
44
+
45
+ def self.included(base)
46
+ base.extend(ClassMethods)
47
+ end
48
+
49
+ def coerce_declared_params(raw_params)
50
+ coerced = {}
51
+ errors = Hash.new { |hash, key| hash[key] = [] }
52
+
53
+ self.class.param_definitions.each_value do |definition|
54
+ raw_value = fetch_param(raw_params, definition.name)
55
+
56
+ if blank_value?(raw_value)
57
+ if definition.default != MISSING
58
+ coerced[definition.name] = definition.default.respond_to?(:call) ? definition.default.call : definition.default
59
+ elsif definition.required
60
+ errors[definition.name.to_s] << "is required"
61
+ end
62
+
63
+ next
64
+ end
65
+
66
+ begin
67
+ coerced_value = coerce_value(raw_value, definition)
68
+ coerced[definition.name] = coerced_value
69
+ rescue ArgumentError, TypeError, JSON::ParserError
70
+ errors[definition.name.to_s] << "is invalid"
71
+ end
72
+ end
73
+
74
+ [coerced, errors]
75
+ end
76
+
77
+ private
78
+
79
+ def fetch_param(raw_params, key)
80
+ raw_params[key] || raw_params[key.to_s]
81
+ end
82
+
83
+ def blank_value?(value)
84
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
85
+ end
86
+
87
+ def coerce_value(raw_value, definition)
88
+ value = cast(raw_value, definition.type)
89
+
90
+ return value unless value.is_a?(String)
91
+
92
+ value = value.strip if definition.strip
93
+ value = value.downcase if definition.downcase
94
+ value = value.upcase if definition.upcase
95
+ value
96
+ end
97
+
98
+ def cast(value, type)
99
+ case type
100
+ when :string
101
+ String(value)
102
+ when :integer
103
+ Integer(value)
104
+ when :boolean
105
+ cast_boolean(value)
106
+ when :float
107
+ Float(value)
108
+ when :date
109
+ Date.parse(String(value))
110
+ when :datetime
111
+ Time.parse(String(value))
112
+ when :array
113
+ cast_array(value)
114
+ when :json
115
+ cast_json(value)
116
+ else
117
+ raise ArgumentError, "Unsupported param type: #{type}"
118
+ end
119
+ end
120
+
121
+ def cast_boolean(value)
122
+ return true if [true, 1, "1", "true", "on", "yes"].include?(value)
123
+ return false if [false, 0, "0", "false", "off", "no"].include?(value)
124
+
125
+ raise TypeError, "Invalid boolean value"
126
+ end
127
+
128
+ def cast_array(value)
129
+ return value if value.is_a?(Array)
130
+
131
+ parsed = JSON.parse(String(value))
132
+ raise TypeError, "Array expected" unless parsed.is_a?(Array)
133
+
134
+ parsed
135
+ end
136
+
137
+ def cast_json(value)
138
+ return value if value.is_a?(Hash) || value.is_a?(Array)
139
+
140
+ JSON.parse(String(value))
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ class Registry
5
+ def initialize
6
+ @map = {}
7
+ end
8
+
9
+ def map(key, handler)
10
+ @map[key] = handler
11
+ end
12
+
13
+ def resolve(key)
14
+ @map[key]
15
+ end
16
+
17
+ def to_h
18
+ @map.dup
19
+ end
20
+
21
+ def clear!
22
+ @map.clear
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ class Resolver
5
+ COMPONENT_PATTERN = /\A[A-Z][A-Za-z0-9_]*\z/
6
+ OPERATION_PATTERN = /\A[a-z][a-z0-9_]*\z/
7
+
8
+ def initialize(configuration: Archipelago.configuration, registry: Archipelago.registry)
9
+ @configuration = configuration
10
+ @registry = registry
11
+ end
12
+
13
+ def resolve(component:, operation:)
14
+ Archipelago.instrument(
15
+ "archipelago.action.resolve",
16
+ component: component,
17
+ operation: operation
18
+ ) do
19
+ validate!(component: component, operation: operation)
20
+
21
+ override = @registry.resolve("#{component}##{operation}")
22
+ return validate_handler!(override) if override
23
+
24
+ constant_name = convention_constant_name(component: component, operation: operation)
25
+ klass = constant_name.safe_constantize
26
+ raise Archipelago::ResolutionError, "Unknown island action" unless klass
27
+
28
+ validate_handler!(klass)
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def validate!(component:, operation:)
35
+ raise Archipelago::ResolutionError, "Invalid component name" unless component.match?(COMPONENT_PATTERN)
36
+ raise Archipelago::ResolutionError, "Invalid operation name" unless operation.match?(OPERATION_PATTERN)
37
+ end
38
+
39
+ def validate_handler!(handler)
40
+ unless handler.is_a?(Class) && handler < Archipelago::Action
41
+ raise Archipelago::ResolutionError, "Resolved handler must inherit from Archipelago::Action"
42
+ end
43
+
44
+ handler
45
+ end
46
+
47
+ def convention_constant_name(component:, operation:)
48
+ component_parts = component.split("__").map { |part| part.underscore.camelize }
49
+ operation_class = operation.camelize
50
+
51
+ [@configuration.root_namespace, *component_parts, operation_class].join("::")
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ module Response
5
+ module_function
6
+
7
+ def ok(props:, version:)
8
+ {
9
+ status: "ok",
10
+ props: props,
11
+ version: version
12
+ }
13
+ end
14
+
15
+ def redirect(location:)
16
+ {
17
+ status: "redirect",
18
+ location: location
19
+ }
20
+ end
21
+
22
+ def error(errors:)
23
+ {
24
+ status: "error",
25
+ errors: errors
26
+ }
27
+ end
28
+
29
+ def forbidden
30
+ {
31
+ status: "forbidden"
32
+ }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ module Security
5
+ class OriginValidator
6
+ def initialize(request, configuration: Archipelago.configuration)
7
+ @request = request
8
+ @configuration = configuration
9
+ end
10
+
11
+ def validate!
12
+ return true unless @configuration.strict_origin_check
13
+
14
+ origin = @request.headers["Origin"]
15
+ return true if origin.nil? || origin.empty?
16
+
17
+ uri = URI.parse(origin)
18
+ expected_scheme = @request.protocol.delete_suffix("://")
19
+
20
+ valid = uri.scheme == expected_scheme &&
21
+ uri.host == @request.host &&
22
+ uri.port == @request.port
23
+
24
+ raise Archipelago::InvalidOrigin, "Origin mismatch" unless valid
25
+
26
+ true
27
+ rescue URI::InvalidURIError
28
+ raise Archipelago::InvalidOrigin, "Invalid origin URI"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ module Security
5
+ class RedirectValidator
6
+ def initialize(configuration: Archipelago.configuration)
7
+ @configuration = configuration
8
+ end
9
+
10
+ def validate!(location)
11
+ return location if relative_path?(location)
12
+
13
+ uri = URI.parse(location)
14
+ unless uri.is_a?(URI::HTTP) && @configuration.allowed_redirect_hosts.include?(uri.host)
15
+ raise Archipelago::InvalidRedirect, "Unsafe redirect host"
16
+ end
17
+
18
+ location
19
+ rescue URI::InvalidURIError
20
+ raise Archipelago::InvalidRedirect, "Invalid redirect URI"
21
+ end
22
+
23
+ private
24
+
25
+ def relative_path?(location)
26
+ location.start_with?("/") && !location.start_with?("//")
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ module TestHelpers
5
+ def island_post(component, operation, params = {})
6
+ post "/islands/#{component}/#{operation}", params: params, as: :json
7
+ end
8
+
9
+ def parsed_island_response
10
+ JSON.parse(response.body)
11
+ end
12
+
13
+ def assert_island_props(key, value)
14
+ body = parsed_island_response
15
+ assert_equal "ok", body["status"]
16
+ assert_equal value, body.fetch("props").fetch(key.to_s)
17
+ end
18
+
19
+ def assert_island_redirect(path)
20
+ body = parsed_island_response
21
+ assert_equal "redirect", body["status"]
22
+ assert_equal path, body["location"]
23
+ end
24
+
25
+ def assert_island_errors(field)
26
+ body = parsed_island_response
27
+ assert_equal "error", body["status"]
28
+ assert body.fetch("errors").key?(field.to_s)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Archipelago
4
+ module ViewHelper
5
+ def archipelago_island(component, props:, params: {}, instance: nil, stream: nil, **html_options)
6
+ stream_name = resolve_stream_name(component: component, instance: instance, stream: stream)
7
+
8
+ data_attributes = {
9
+ island: true,
10
+ component: component,
11
+ props: props.to_json,
12
+ params: params.to_json,
13
+ instance: instance,
14
+ stream: stream_name
15
+ }.compact
16
+
17
+ content_tag(:div, "", html_options.merge(data: data_attributes))
18
+ end
19
+
20
+ private
21
+
22
+ def resolve_stream_name(component:, instance:, stream:)
23
+ return nil if stream.nil?
24
+ return stream if stream.is_a?(String)
25
+
26
+ raise ArgumentError, "instance is required when stream: true" if instance.blank?
27
+
28
+ "#{component}:#{instance}"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "archipelago"
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+ require "active_support/core_ext/hash/indifferent_access"
5
+ require "active_support/core_ext/object/blank"
6
+ require "active_support/core_ext/string/inflections"
7
+ require "active_support/notifications"
8
+ require "json"
9
+ require "uri"
10
+
11
+ module Archipelago
12
+ class Error < StandardError; end
13
+ class Forbidden < Error; end
14
+ class ResolutionError < Error; end
15
+ class InvalidOrigin < Error; end
16
+ class InvalidRedirect < Error; end
17
+ class MissingAuthorization < Error; end
18
+
19
+ class << self
20
+ def configure
21
+ yield(configuration)
22
+ end
23
+
24
+ def configuration
25
+ @configuration ||= Configuration.new
26
+ end
27
+
28
+ def reset_configuration!
29
+ @configuration = Configuration.new
30
+ end
31
+
32
+ def registry
33
+ @registry ||= Registry.new
34
+ end
35
+
36
+ def map(mapping)
37
+ mapping.each { |key, value| registry.map(key, value) }
38
+ end
39
+
40
+ def next_version
41
+ configuration.version_source.call
42
+ end
43
+
44
+ def broadcast(stream_name, props:, version: next_version)
45
+ Broadcasts.broadcast(stream_name, props: props, version: version)
46
+ end
47
+
48
+ def instrument(event, payload = {}, &block)
49
+ ActiveSupport::Notifications.instrument(event, payload, &block)
50
+ end
51
+ end
52
+ end
53
+
54
+ require "archipelago/configuration"
55
+ require "archipelago/registry"
56
+ require "archipelago/response"
57
+ require "archipelago/params_dsl"
58
+ require "archipelago/context"
59
+ require "archipelago/security/origin_validator"
60
+ require "archipelago/security/redirect_validator"
61
+ require "archipelago/action"
62
+ require "archipelago/resolver"
63
+ require "archipelago/broadcasts"
64
+ require "archipelago/channel"
65
+ require "archipelago/view_helper"
66
+ require "archipelago/test_helpers"
67
+ begin
68
+ require "archipelago/engine"
69
+ rescue LoadError, NameError
70
+ # Engine support requires railties and loads when running inside Rails.
71
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Archipelago
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ desc "Installs Archipelago into a Rails app"
9
+
10
+ def create_islands_directory
11
+ empty_directory "app/islands"
12
+ end
13
+
14
+ def create_initializer
15
+ create_file "config/initializers/archipelago.rb", <<~RUBY
16
+ Archipelago.configure do |config|
17
+ config.root_namespace = "Islands"
18
+ config.current_user_method = :current_user
19
+ config.authorize_by_default = true
20
+ config.strict_origin_check = false
21
+ config.allowed_redirect_hosts = []
22
+ end
23
+ RUBY
24
+ end
25
+
26
+ def mount_engine
27
+ route_line = "mount Archipelago::Engine => \"/islands\""
28
+ routes_path = "config/routes.rb"
29
+
30
+ if File.exist?(routes_path) && !File.read(routes_path).include?(route_line)
31
+ route(route_line)
32
+ end
33
+ end
34
+
35
+ def print_next_steps
36
+ say "Install JS packages: yarn add @archipelago-js/client @archipelago-js/react"
37
+ say "Optional React bootstrap wizard: rails g archipelago:install:react"
38
+ say "Non-interactive mode: rails g archipelago:install:react --interactive=false"
39
+ say "esbuild users get auto-registry wiring by default in install:react"
40
+ end
41
+ end
42
+ end
43
+ end