blocks-sdk 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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require_relative "../base_client"
6
+
7
+ module Blocks
8
+ module Sdk
9
+ module Clients
10
+ # Client for UILM Translation Service
11
+ class TranslationsClient < BaseClient
12
+ class << self
13
+ def service_name
14
+ "translations"
15
+ end
16
+ end
17
+
18
+ def initialize(config = {})
19
+ super(config)
20
+ @base_url = config[:base_url] || ENV.fetch("UILM_BASE_URL")
21
+ @project_key = config[:project_key] || ENV.fetch("UILM_PROJECT_KEY")
22
+ end
23
+
24
+ def fetch(module_name:, language: "en")
25
+ conn = build_connection
26
+
27
+ request_params = {
28
+ ModuleName: module_name,
29
+ ProjectKey: @project_key,
30
+ Language: normalize_language(language)
31
+ }
32
+
33
+ response = conn.get(
34
+ @base_url,
35
+ request_params,
36
+ build_headers
37
+ )
38
+
39
+ handle_response(response)
40
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
41
+ log_error("UILM API connection failed: #{e.message}")
42
+ { error: "Connection failed", status: :connection_error }
43
+ rescue StandardError => e
44
+ log_error("Unexpected error in TranslationsClient: #{e.message}")
45
+ { error: "Unexpected error occurred", status: :error }
46
+ end
47
+
48
+ def handle_webhook(payload)
49
+ # Extract cache invalidation parameters from webhook payload
50
+ # Expected payload structure: { module_name: "...", language: "..." }
51
+ module_name = payload[:module_name] || payload["module_name"]
52
+ language = payload[:language] || payload["language"] || "en"
53
+
54
+ {
55
+ service: service_name,
56
+ module_name: module_name,
57
+ language: language,
58
+ action: :invalidate_cache
59
+ }
60
+ end
61
+
62
+ private
63
+
64
+ def build_connection
65
+ Faraday.new do |f|
66
+ f.adapter Faraday.default_adapter
67
+ end
68
+ end
69
+
70
+ def build_headers
71
+ {
72
+ "Accept" => "application/json",
73
+ "Content-Type" => "application/json",
74
+ "x-blocks-key" => @project_key
75
+ }
76
+ end
77
+
78
+ def normalize_language(language)
79
+ case language.to_s.downcase
80
+ when "en"
81
+ "en-US"
82
+ when "de"
83
+ "de-DE"
84
+ else
85
+ language
86
+ end
87
+ end
88
+
89
+ def handle_response(response)
90
+ unless response.success?
91
+ return { error: "Failed to fetch translations", status: response.status }
92
+ end
93
+
94
+ if response.body.nil? || response.body.strip.empty?
95
+ return { error: "Empty response from UILM API", status: :empty_response }
96
+ end
97
+
98
+ begin
99
+ parsed_response = JSON.parse(response.body)
100
+ { translations: normalize(parsed_response), status: :success }
101
+ rescue JSON::ParserError => _e
102
+ { error: "Invalid JSON response from UILM API", status: :parse_error }
103
+ rescue StandardError => _e
104
+ { error: "Unexpected error occurred", status: :error }
105
+ end
106
+ end
107
+
108
+ # UILM ships {{var}} placeholders; I18n interpolates %{var}.
109
+ # Keys are also downcased so I18n.t resolves regardless of caller casing.
110
+ PLACEHOLDER_RE = /\{\{\s*([\w.]+)\s*\}\}/
111
+
112
+ def normalize(value)
113
+ case value
114
+ when String
115
+ value.gsub(PLACEHOLDER_RE) { "%{#{Regexp.last_match(1)}}" }
116
+ when Hash
117
+ value.each_with_object({}) { |(k, v), h| h[k.to_s.downcase] = normalize(v) }
118
+ when Array
119
+ value.map { |v| normalize(v) }
120
+ else
121
+ value
122
+ end
123
+ end
124
+
125
+ def log_error(message)
126
+ if defined?(Rails)
127
+ Rails.logger.error(message)
128
+ else
129
+ warn(message)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+
@@ -0,0 +1,38 @@
1
+ module Blocks
2
+ module Sdk
3
+ # Configuration for Blocks SDK
4
+ class Configuration
5
+ attr_accessor :uilm_base_url, :uilm_project_key
6
+ attr_accessor :webhook_secret, :webhook_path_prefix
7
+ attr_accessor :cache_enabled, :logger
8
+ attr_accessor :install_i18n_backend, :default_module_name
9
+ attr_accessor :translation_modules, :translation_languages
10
+
11
+ def initialize
12
+ @uilm_base_url = ENV.fetch("UILM_BASE_URL", nil)
13
+ @uilm_project_key = ENV.fetch("UILM_PROJECT_KEY", nil)
14
+ @webhook_secret = ENV.fetch("BLOCKS_WEBHOOK_SECRET", nil)
15
+ @webhook_path_prefix = "/blocks/webhooks"
16
+ @cache_enabled = true
17
+ @logger = nil
18
+ @install_i18n_backend = false
19
+ @default_module_name = ENV.fetch("BLOCKS_DEFAULT_MODULE_NAME", nil)
20
+ @translation_modules = nil
21
+ @translation_languages = nil
22
+ end
23
+
24
+ def to_h
25
+ {
26
+ uilm_base_url: @uilm_base_url,
27
+ uilm_project_key: @uilm_project_key,
28
+ webhook_secret: @webhook_secret,
29
+ webhook_path_prefix: @webhook_path_prefix,
30
+ cache_enabled: @cache_enabled,
31
+ install_i18n_backend: @install_i18n_backend,
32
+ default_module_name: @default_module_name
33
+ }
34
+ end
35
+ end
36
+ end
37
+ end
38
+
@@ -0,0 +1,53 @@
1
+ module Blocks
2
+ module Sdk
3
+ module Rails
4
+ module Controllers
5
+ module Api
6
+ # API controller for fetching translations
7
+ class TranslationsController < ActionController::API
8
+ before_action :set_service_manager
9
+
10
+ # GET /blocks/api/translations
11
+ # Query params: module_name, language (optional, defaults to "en")
12
+ def index
13
+ module_name = params[:module_name] || params[:moduleName]
14
+ language = params[:language] || params[:Language] || "en"
15
+
16
+ unless module_name
17
+ return render json: { error: "module_name is required" }, status: :bad_request
18
+ end
19
+
20
+ result = @service_manager.fetch(
21
+ service_name: "translations",
22
+ params: { module_name: module_name, language: language },
23
+ config: client_config
24
+ )
25
+
26
+ if result[:error]
27
+ status = result[:status] == :connection_error ? :service_unavailable : :bad_gateway
28
+ render json: result, status: status
29
+ else
30
+ render json: result[:translations] || result, status: :ok
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def set_service_manager
37
+ @service_manager = Blocks::Sdk.service_manager
38
+ end
39
+
40
+ def client_config
41
+ {
42
+ base_url: Blocks::Sdk.config.uilm_base_url || ENV.fetch("UILM_BASE_URL"),
43
+ project_key: Blocks::Sdk.config.uilm_project_key || ENV.fetch("UILM_PROJECT_KEY")
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+
@@ -0,0 +1,62 @@
1
+ module Blocks
2
+ module Sdk
3
+ module Rails
4
+ module Controllers
5
+ # Webhook controller specifically for Translations service
6
+ # Other services should implement their own webhook controllers
7
+ class TranslationsWebhooksController < ActionController::API
8
+ before_action :set_service_manager
9
+ before_action :verify_webhook_secret, if: -> { webhook_secret_configured? }
10
+
11
+ # POST /blocks/webhooks/translations
12
+ # Handles webhook callbacks for translations cache invalidation
13
+ def create
14
+ result = @service_manager.handle_webhook(
15
+ service_name: "translations",
16
+ payload: webhook_params,
17
+ config: client_config
18
+ )
19
+
20
+ if result[:success]
21
+ render json: result, status: :ok
22
+ else
23
+ render json: result, status: :unprocessable_entity
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def set_service_manager
30
+ @service_manager = Blocks::Sdk.service_manager
31
+ end
32
+
33
+ def webhook_params
34
+ params.except(:controller, :action).permit!.to_h
35
+ end
36
+
37
+ def client_config
38
+ {
39
+ base_url: Blocks::Sdk.config.uilm_base_url,
40
+ project_key: Blocks::Sdk.config.uilm_project_key
41
+ }
42
+ end
43
+
44
+ def verify_webhook_secret
45
+ provided_secret = request.headers["X-Blocks-Webhook-Secret"] || request.headers["x-blocks-webhook-secret"]
46
+ expected_secret = Blocks::Sdk.config.webhook_secret
47
+
48
+ unless provided_secret == expected_secret
49
+ render json: { error: "Invalid webhook secret" }, status: :unauthorized
50
+ end
51
+ end
52
+
53
+ def webhook_secret_configured?
54
+ secret = Blocks::Sdk.config.webhook_secret
55
+ !secret.nil? && !secret.to_s.strip.empty?
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+
@@ -0,0 +1,79 @@
1
+ # I18n backend for Blocks SDK translations.
2
+ #
3
+ # Reads through ServiceManager so the same MemoryCache that holds boot-time
4
+ # preloads and webhook-invalidated entries is the single source of truth.
5
+ # Falls back to the Simple backend (YAML files) when a key is not found in
6
+ # UILM, so existing app translations keep working.
7
+ module Blocks
8
+ module Sdk
9
+ module Rails
10
+ class I18nBackend < ::I18n::Backend::Simple
11
+ def initialize(service_manager: nil, config: {})
12
+ super()
13
+ @service_manager = service_manager || Blocks::Sdk.service_manager
14
+ @config = config
15
+ @default_module_name = config[:default_module_name] ||
16
+ Blocks::Sdk.config.default_module_name
17
+ end
18
+
19
+ protected
20
+
21
+ def lookup(locale, key, scope = [], options = {})
22
+ module_name, scope_consumed = extract_module_name(scope, options)
23
+ return super unless module_name
24
+
25
+ translations = fetch_module_translations(module_name, locale)
26
+ return super unless translations.is_a?(Hash)
27
+
28
+ remaining_scope = scope_consumed ? [] : scope
29
+ path = ::I18n.normalize_keys(nil, key, remaining_scope, options[:separator])
30
+
31
+ navigate(translations, path) || super
32
+ end
33
+
34
+ private
35
+
36
+ def fetch_module_translations(module_name, locale)
37
+ result = @service_manager.fetch(
38
+ service_name: "translations",
39
+ params: { module_name: module_name.to_s, language: locale.to_s },
40
+ config: {
41
+ base_url: Blocks::Sdk.config.uilm_base_url,
42
+ project_key: Blocks::Sdk.config.uilm_project_key
43
+ }
44
+ )
45
+
46
+ result.is_a?(Hash) ? result[:translations] : nil
47
+ end
48
+
49
+ # Returns [module_name, scope_consumed?]. scope_consumed signals that
50
+ # the caller used scope.first as the module, so the navigator should
51
+ # skip it when walking the translations hash.
52
+ def extract_module_name(scope, options)
53
+ return [options[:module_name], false] if options[:module_name]
54
+ return [scope.first, true] if scope.is_a?(Array) && scope.any?
55
+
56
+ [@default_module_name, false]
57
+ end
58
+
59
+ # Walks the translations hash for nested payloads, then falls back to
60
+ # a dot-joined flat key (UILM payloads in this project are flat).
61
+ # Cache keys are stored downcased, so the lookup downcases too.
62
+ def navigate(translations, keys)
63
+ return nil if keys.nil? || keys.empty?
64
+
65
+ downcased = keys.map { |k| k.to_s.downcase }
66
+
67
+ nested = downcased.inject(translations) do |hash, k|
68
+ break nil unless hash.is_a?(Hash)
69
+ hash[k] || hash[k.to_sym]
70
+ end
71
+ return nested unless nested.nil?
72
+
73
+ flat_key = downcased.join(".")
74
+ translations[flat_key] || translations[flat_key.to_sym]
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,25 @@
1
+ # Rails initializer template
2
+ # Copy this to config/initializers/blocks_sdk.rb in your Rails app
3
+
4
+ # Blocks::Sdk.configure do |config|
5
+ # config.uilm_base_url = ENV.fetch("UILM_BASE_URL")
6
+ # config.uilm_project_key = ENV.fetch("UILM_PROJECT_KEY")
7
+ # config.webhook_secret = ENV.fetch("BLOCKS_WEBHOOK_SECRET", nil)
8
+ # config.cache_enabled = true
9
+ # config.logger = Rails.logger
10
+ #
11
+ # # Make UILM the data source for I18n.t. The backend reads through
12
+ # # the SDK's MemoryCache, so boot-time preloads and webhook
13
+ # # invalidations are visible to I18n immediately.
14
+ # config.install_i18n_backend = true
15
+ # config.default_module_name = "your_module"
16
+ #
17
+ # # Modules to preload at boot. Languages default to
18
+ # # I18n.available_locales + I18n.locale + I18n.default_locale.
19
+ # config.translation_modules = ["auth", "users"]
20
+ # # config.translation_languages = ["en", "de"] # override if needed
21
+ # end
22
+ #
23
+ # Or via env vars:
24
+ # BLOCKS_TRANSLATION_MODULES=module1,module2
25
+ # BLOCKS_TRANSLATION_LANGUAGES=en,de # optional; defaults to I18n locales
@@ -0,0 +1,33 @@
1
+ require "rails"
2
+
3
+ module Blocks
4
+ module Sdk
5
+ module Rails
6
+ class Railtie < ::Rails::Railtie
7
+ initializer "blocks_sdk.initialize" do |app|
8
+ Blocks::Sdk.configure do |config|
9
+ config.logger = app.config.logger if app.config.respond_to?(:logger)
10
+ end
11
+
12
+ app.config.after_initialize do
13
+ if Blocks::Sdk.config.install_i18n_backend
14
+ require_relative "i18n_backend"
15
+ ::I18n.backend = Blocks::Sdk::Rails::I18nBackend.new
16
+ end
17
+
18
+ Blocks::Sdk.load_translations_on_boot
19
+ end
20
+ end
21
+
22
+ initializer "blocks_sdk.routes", after: :set_routes_reloader do |app|
23
+ app.routes.append do
24
+ # Register routes for translations service
25
+ # Other services should register their own routes in their own initializers
26
+ Blocks::Sdk::Rails::TranslationsRouteMapper.draw(self)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+
@@ -0,0 +1,17 @@
1
+ require_relative "translations_route_mapper"
2
+
3
+ # This module is kept for backward compatibility
4
+ # Routes are now handled by TranslationsRouteMapper
5
+ # Other services should implement their own route mappers
6
+ module Blocks
7
+ module Sdk
8
+ module Rails
9
+ module Routes
10
+ def self.draw(router)
11
+ TranslationsRouteMapper.draw(router)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Blocks
4
+ module Sdk
5
+ module Rails
6
+ # Maps routes specifically for Translations service
7
+ # Other services should implement their own route mappers
8
+ class TranslationsRouteMapper
9
+ def self.draw(router)
10
+ router.scope path: "blocks" do
11
+ # API endpoints for frontend - translations service only
12
+ router.get "api/translations", to: "blocks/sdk/rails/controllers/api/translations#index", as: :api_translations
13
+
14
+ # Webhook endpoint for translations service cache invalidation
15
+ router.post "webhooks/translations", to: "blocks/sdk/rails/controllers/translations_webhooks#create", as: :webhooks_translations
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails integration module
4
+ # This file ensures Rails controllers are properly loaded for translations service
5
+ # Other services should require their own controllers in their own integration files
6
+
7
+ if defined?(Rails)
8
+ require_relative "rails/translations_route_mapper"
9
+ require_relative "rails/controllers/api/translations_controller"
10
+ require_relative "rails/controllers/translations_webhooks_controller"
11
+ end
12
+
@@ -0,0 +1,93 @@
1
+ require_relative "cache/memory_cache"
2
+ require_relative "clients/translations_client"
3
+
4
+ module Blocks
5
+ module Sdk
6
+ # Manages all Blocks Service clients and their caching
7
+ class ServiceManager
8
+ attr_reader :cache, :clients
9
+
10
+ def initialize
11
+ @cache = Cache::MemoryCache.new
12
+ @clients = {}
13
+ register_default_clients
14
+ end
15
+
16
+ # Register a client for a service
17
+ # @param service_name [String] Name of the service
18
+ # @param client_class [Class] Client class
19
+ def register_client(service_name, client_class)
20
+ @clients[service_name.to_s] = client_class
21
+ end
22
+
23
+ # Get client instance for a service
24
+ # @param service_name [String] Name of the service
25
+ # @param config [Hash] Configuration for the client
26
+ # @return [BaseClient] Client instance
27
+ def client(service_name, config = {})
28
+ client_class = @clients[service_name.to_s]
29
+ raise ArgumentError, "Client not found for service: #{service_name}" unless client_class
30
+
31
+ client_class.new(config)
32
+ end
33
+
34
+ # Fetch data from a service with caching
35
+ # @param service_name [String] Name of the service
36
+ # @param params [Hash] Parameters for fetching
37
+ # @param config [Hash] Client configuration
38
+ # @return [Hash] Service response
39
+ def fetch(service_name:, params:, config: {})
40
+ cache_key = build_cache_key(service_name, params)
41
+ cached_value = @cache.get(cache_key)
42
+
43
+ return cached_value if cached_value
44
+
45
+ client_instance = client(service_name, config)
46
+ result = client_instance.fetch(**params)
47
+
48
+ # Cache successful responses
49
+ if result[:status] == :success || result[:translations]
50
+ @cache.set(cache_key, result)
51
+ end
52
+
53
+ result
54
+ end
55
+
56
+ # Handle webhook callback and invalidate cache
57
+ # @param service_name [String] Name of the service
58
+ # @param payload [Hash] Webhook payload
59
+ # @param config [Hash] Client configuration
60
+ # @return [Hash] Webhook handling result
61
+ def handle_webhook(service_name:, payload:, config: {})
62
+ client_instance = client(service_name, config)
63
+ webhook_result = client_instance.handle_webhook(payload)
64
+
65
+ # Invalidate cache based on webhook result
66
+ if webhook_result[:action] == :invalidate_cache
67
+ @cache.invalidate(
68
+ service: service_name,
69
+ module_name: webhook_result[:module_name],
70
+ language: webhook_result[:language]
71
+ )
72
+ end
73
+
74
+ { success: true, service: service_name, webhook_result: webhook_result }
75
+ rescue StandardError => e
76
+ { success: false, error: e.message }
77
+ end
78
+
79
+ private
80
+
81
+ def register_default_clients
82
+ register_client("translations", Clients::TranslationsClient)
83
+ end
84
+
85
+ def build_cache_key(service_name, params)
86
+ module_name = params[:module_name] || params["module_name"]
87
+ language = params[:language] || params["language"] || "en"
88
+ "#{service_name}:#{module_name}:#{language}"
89
+ end
90
+ end
91
+ end
92
+ end
93
+
@@ -0,0 +1,5 @@
1
+ module Blocks
2
+ module Sdk
3
+ VERSION = "0.1.0"
4
+ end
5
+ end