factorix 0.5.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 (202) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +20 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +105 -0
  5. data/completion/_factorix.bash +202 -0
  6. data/completion/_factorix.fish +197 -0
  7. data/completion/_factorix.zsh +376 -0
  8. data/doc/factorix.1 +377 -0
  9. data/exe/factorix +20 -0
  10. data/lib/factorix/api/category.rb +69 -0
  11. data/lib/factorix/api/image.rb +35 -0
  12. data/lib/factorix/api/license.rb +71 -0
  13. data/lib/factorix/api/mod_download_api.rb +66 -0
  14. data/lib/factorix/api/mod_info.rb +166 -0
  15. data/lib/factorix/api/mod_management_api.rb +237 -0
  16. data/lib/factorix/api/mod_portal_api.rb +204 -0
  17. data/lib/factorix/api/release.rb +49 -0
  18. data/lib/factorix/api/tag.rb +95 -0
  19. data/lib/factorix/api.rb +7 -0
  20. data/lib/factorix/api_credential.rb +54 -0
  21. data/lib/factorix/application.rb +218 -0
  22. data/lib/factorix/cache/file_system.rb +307 -0
  23. data/lib/factorix/cli/commands/backup_support.rb +46 -0
  24. data/lib/factorix/cli/commands/base.rb +90 -0
  25. data/lib/factorix/cli/commands/cache/evict.rb +180 -0
  26. data/lib/factorix/cli/commands/cache/stat.rb +201 -0
  27. data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
  28. data/lib/factorix/cli/commands/completion.rb +83 -0
  29. data/lib/factorix/cli/commands/confirmable.rb +53 -0
  30. data/lib/factorix/cli/commands/download_support.rb +123 -0
  31. data/lib/factorix/cli/commands/launch.rb +79 -0
  32. data/lib/factorix/cli/commands/man.rb +29 -0
  33. data/lib/factorix/cli/commands/mod/check.rb +99 -0
  34. data/lib/factorix/cli/commands/mod/disable.rb +188 -0
  35. data/lib/factorix/cli/commands/mod/download.rb +291 -0
  36. data/lib/factorix/cli/commands/mod/edit.rb +114 -0
  37. data/lib/factorix/cli/commands/mod/enable.rb +216 -0
  38. data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
  39. data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
  40. data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
  41. data/lib/factorix/cli/commands/mod/install.rb +443 -0
  42. data/lib/factorix/cli/commands/mod/list.rb +372 -0
  43. data/lib/factorix/cli/commands/mod/search.rb +134 -0
  44. data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
  45. data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
  46. data/lib/factorix/cli/commands/mod/show.rb +202 -0
  47. data/lib/factorix/cli/commands/mod/sync.rb +299 -0
  48. data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
  49. data/lib/factorix/cli/commands/mod/update.rb +222 -0
  50. data/lib/factorix/cli/commands/mod/upload.rb +90 -0
  51. data/lib/factorix/cli/commands/path.rb +79 -0
  52. data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
  53. data/lib/factorix/cli/commands/version.rb +25 -0
  54. data/lib/factorix/cli.rb +42 -0
  55. data/lib/factorix/dependency/edge.rb +89 -0
  56. data/lib/factorix/dependency/entry.rb +124 -0
  57. data/lib/factorix/dependency/graph/builder.rb +108 -0
  58. data/lib/factorix/dependency/graph.rb +210 -0
  59. data/lib/factorix/dependency/list.rb +244 -0
  60. data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
  61. data/lib/factorix/dependency/node.rb +60 -0
  62. data/lib/factorix/dependency/parser.rb +148 -0
  63. data/lib/factorix/dependency/validation_result.rb +138 -0
  64. data/lib/factorix/dependency/validator.rb +190 -0
  65. data/lib/factorix/errors.rb +112 -0
  66. data/lib/factorix/formatting.rb +56 -0
  67. data/lib/factorix/game_version.rb +98 -0
  68. data/lib/factorix/http/cache_decorator.rb +106 -0
  69. data/lib/factorix/http/cached_response.rb +37 -0
  70. data/lib/factorix/http/client.rb +187 -0
  71. data/lib/factorix/http/response.rb +31 -0
  72. data/lib/factorix/http/retry_decorator.rb +59 -0
  73. data/lib/factorix/http/retry_strategy.rb +80 -0
  74. data/lib/factorix/info_json.rb +90 -0
  75. data/lib/factorix/installed_mod.rb +239 -0
  76. data/lib/factorix/mod.rb +55 -0
  77. data/lib/factorix/mod_list.rb +174 -0
  78. data/lib/factorix/mod_settings.rb +278 -0
  79. data/lib/factorix/mod_state.rb +34 -0
  80. data/lib/factorix/mod_version.rb +99 -0
  81. data/lib/factorix/portal.rb +185 -0
  82. data/lib/factorix/progress/download_handler.rb +46 -0
  83. data/lib/factorix/progress/multi_presenter.rb +45 -0
  84. data/lib/factorix/progress/presenter.rb +67 -0
  85. data/lib/factorix/progress/presenter_adapter.rb +46 -0
  86. data/lib/factorix/progress/scan_handler.rb +33 -0
  87. data/lib/factorix/progress/upload_handler.rb +33 -0
  88. data/lib/factorix/runtime/base.rb +233 -0
  89. data/lib/factorix/runtime/linux.rb +32 -0
  90. data/lib/factorix/runtime/mac_os.rb +53 -0
  91. data/lib/factorix/runtime/user_configurable.rb +69 -0
  92. data/lib/factorix/runtime/windows.rb +85 -0
  93. data/lib/factorix/runtime/wsl.rb +118 -0
  94. data/lib/factorix/runtime.rb +32 -0
  95. data/lib/factorix/save_file.rb +178 -0
  96. data/lib/factorix/ser_des/deserializer.rb +198 -0
  97. data/lib/factorix/ser_des/serializer.rb +231 -0
  98. data/lib/factorix/ser_des/signed_integer.rb +63 -0
  99. data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
  100. data/lib/factorix/service_credential.rb +127 -0
  101. data/lib/factorix/transfer/downloader.rb +162 -0
  102. data/lib/factorix/transfer/uploader.rb +232 -0
  103. data/lib/factorix/version.rb +6 -0
  104. data/lib/factorix.rb +38 -0
  105. data/sig/dry/auto_inject.rbs +15 -0
  106. data/sig/dry/cli.rbs +19 -0
  107. data/sig/dry/configurable.rbs +13 -0
  108. data/sig/dry/core/container.rbs +17 -0
  109. data/sig/dry/events/publisher.rbs +22 -0
  110. data/sig/dry/logger.rbs +16 -0
  111. data/sig/factorix/api/category.rbs +15 -0
  112. data/sig/factorix/api/image.rbs +15 -0
  113. data/sig/factorix/api/license.rbs +20 -0
  114. data/sig/factorix/api/mod_download_api.rbs +18 -0
  115. data/sig/factorix/api/mod_info.rbs +67 -0
  116. data/sig/factorix/api/mod_management_api.rbs +25 -0
  117. data/sig/factorix/api/mod_portal_api.rbs +31 -0
  118. data/sig/factorix/api/release.rbs +27 -0
  119. data/sig/factorix/api/tag.rbs +15 -0
  120. data/sig/factorix/api.rbs +8 -0
  121. data/sig/factorix/api_credential.rbs +17 -0
  122. data/sig/factorix/application.rbs +86 -0
  123. data/sig/factorix/cache/file_system.rbs +35 -0
  124. data/sig/factorix/cli/commands/base.rbs +13 -0
  125. data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
  126. data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
  127. data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
  128. data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
  129. data/sig/factorix/cli/commands/confirmable.rbs +12 -0
  130. data/sig/factorix/cli/commands/download_support.rbs +12 -0
  131. data/sig/factorix/cli/commands/launch.rbs +15 -0
  132. data/sig/factorix/cli/commands/mod/check.rbs +18 -0
  133. data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
  134. data/sig/factorix/cli/commands/mod/download.rbs +18 -0
  135. data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
  136. data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
  137. data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
  138. data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
  139. data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
  140. data/sig/factorix/cli/commands/mod/install.rbs +19 -0
  141. data/sig/factorix/cli/commands/mod/list.rbs +30 -0
  142. data/sig/factorix/cli/commands/mod/search.rbs +18 -0
  143. data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
  144. data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
  145. data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
  146. data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
  147. data/sig/factorix/cli/commands/mod/update.rbs +19 -0
  148. data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
  149. data/sig/factorix/cli/commands/path.rbs +18 -0
  150. data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
  151. data/sig/factorix/cli/commands/version.rbs +13 -0
  152. data/sig/factorix/cli.rbs +11 -0
  153. data/sig/factorix/dependency/edge.rbs +32 -0
  154. data/sig/factorix/dependency/entry.rbs +30 -0
  155. data/sig/factorix/dependency/graph/builder.rbs +17 -0
  156. data/sig/factorix/dependency/graph.rbs +39 -0
  157. data/sig/factorix/dependency/list.rbs +69 -0
  158. data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
  159. data/sig/factorix/dependency/node.rbs +24 -0
  160. data/sig/factorix/dependency/parser.rbs +11 -0
  161. data/sig/factorix/dependency/validation_result.rbs +56 -0
  162. data/sig/factorix/dependency/validator.rbs +13 -0
  163. data/sig/factorix/errors.rbs +132 -0
  164. data/sig/factorix/formatting.rbs +8 -0
  165. data/sig/factorix/game_version.rbs +24 -0
  166. data/sig/factorix/http/cache_decorator.rbs +64 -0
  167. data/sig/factorix/http/client.rbs +55 -0
  168. data/sig/factorix/http/response.rbs +28 -0
  169. data/sig/factorix/http/retry_decorator.rbs +44 -0
  170. data/sig/factorix/http/retry_strategy.rbs +42 -0
  171. data/sig/factorix/info_json.rbs +19 -0
  172. data/sig/factorix/installed_mod.rbs +34 -0
  173. data/sig/factorix/mod.rbs +20 -0
  174. data/sig/factorix/mod_list.rbs +44 -0
  175. data/sig/factorix/mod_settings.rbs +47 -0
  176. data/sig/factorix/mod_state.rbs +18 -0
  177. data/sig/factorix/mod_version.rbs +23 -0
  178. data/sig/factorix/portal.rbs +37 -0
  179. data/sig/factorix/progress/download_handler.rbs +19 -0
  180. data/sig/factorix/progress/multi_presenter.rbs +15 -0
  181. data/sig/factorix/progress/presenter.rbs +17 -0
  182. data/sig/factorix/progress/presenter_adapter.rbs +17 -0
  183. data/sig/factorix/progress/scan_handler.rbs +16 -0
  184. data/sig/factorix/progress/upload_handler.rbs +17 -0
  185. data/sig/factorix/runtime/base.rbs +45 -0
  186. data/sig/factorix/runtime/linux.rbs +15 -0
  187. data/sig/factorix/runtime/mac_os.rbs +15 -0
  188. data/sig/factorix/runtime/user_configurable.rbs +13 -0
  189. data/sig/factorix/runtime/windows.rbs +23 -0
  190. data/sig/factorix/runtime/wsl.rbs +19 -0
  191. data/sig/factorix/runtime.rbs +9 -0
  192. data/sig/factorix/save_file.rbs +40 -0
  193. data/sig/factorix/ser_des/deserializer.rbs +49 -0
  194. data/sig/factorix/ser_des/serializer.rbs +45 -0
  195. data/sig/factorix/ser_des/signed_integer.rbs +37 -0
  196. data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
  197. data/sig/factorix/service_credential.rbs +19 -0
  198. data/sig/factorix/transfer/downloader.rbs +15 -0
  199. data/sig/factorix/transfer/uploader.rbs +21 -0
  200. data/sig/factorix.rbs +9 -0
  201. data/sig/tty/progressbar.rbs +18 -0
  202. metadata +431 -0
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ GameVersion = Data.define(:major, :minor, :patch, :build)
5
+
6
+ # Represent a 4-component game version number (major.minor.patch-build)
7
+ #
8
+ # This class represents Factorio's game version format, which uses
9
+ # 64 bits (4 x 16-bit unsigned integers) to store version information.
10
+ #
11
+ # @see https://wiki.factorio.com/Version_string_format
12
+ class GameVersion
13
+ include Comparable
14
+
15
+ # @!attribute [r] major
16
+ # @return [Integer] major version number (0-65535)
17
+ # @!attribute [r] minor
18
+ # @return [Integer] minor version number (0-65535)
19
+ # @!attribute [r] patch
20
+ # @return [Integer] patch version number (0-65535)
21
+ # @!attribute [r] build
22
+ # @return [Integer] build version number (0-65535)
23
+
24
+ UINT16_MAX = (2**16) - 1
25
+ private_constant :UINT16_MAX
26
+
27
+ class << self
28
+ private def validate_component(value, name)
29
+ raise VersionParseError, "#{name} must be an Integer, got #{value.class}" unless value.is_a?(Integer)
30
+ return if value.between?(0, UINT16_MAX)
31
+
32
+ raise VersionParseError, "#{name} must be between 0 and #{UINT16_MAX}, got #{value}"
33
+ end
34
+ end
35
+
36
+ # Create GameVersion from version string "X.Y.Z-B" or "X.Y.Z"
37
+ #
38
+ # @param str [String] version string in "X.Y.Z-B" format (build defaults to 0 if omitted)
39
+ # @return [GameVersion]
40
+ # @raise [VersionParseError] if string format is invalid
41
+ def self.from_string(str)
42
+ unless /\A(\d+)\.(\d+)\.(\d+)(?:-(\d+))?\z/ =~ str
43
+ raise VersionParseError, "invalid version string: #{str.inspect}"
44
+ end
45
+
46
+ major = Integer($1)
47
+ minor = Integer($2)
48
+ patch = Integer($3)
49
+ build = $4.nil? ? 0 : Integer($4)
50
+
51
+ validate_component(major, :major)
52
+ validate_component(minor, :minor)
53
+ validate_component(patch, :patch)
54
+ validate_component(build, :build)
55
+
56
+ new(major:, minor:, patch:, build:)
57
+ end
58
+
59
+ # Create GameVersion from four integers
60
+ #
61
+ # @param major [Integer] major version number (0-65535)
62
+ # @param minor [Integer] minor version number (0-65535)
63
+ # @param patch [Integer] patch version number (0-65535)
64
+ # @param build [Integer] build version number (0-65535, defaults to 0)
65
+ # @return [GameVersion]
66
+ # @raise [VersionParseError] if any component is out of range
67
+ def self.from_numbers(major, minor, patch, build=0)
68
+ validate_component(major, :major)
69
+ validate_component(minor, :minor)
70
+ validate_component(patch, :patch)
71
+ validate_component(build, :build)
72
+
73
+ new(major:, minor:, patch:, build:)
74
+ end
75
+
76
+ private_class_method :new, :[]
77
+
78
+ # Convert to string representation
79
+ #
80
+ # @return [String] Version string in format "X.Y.Z-B" or "X.Y.Z" if build is 0
81
+ def to_s = build.zero? ? "#{major}.#{minor}.#{patch}" : "#{major}.#{minor}.#{patch}-#{build}"
82
+
83
+ # Convert to array of integers
84
+ #
85
+ # @return [Array<Integer>] Array containing [major, minor, patch, build]
86
+ def to_a = [major, minor, patch, build].freeze
87
+
88
+ # Compare with another GameVersion
89
+ #
90
+ # @param other [GameVersion] Version to compare with
91
+ # @return [Integer, nil] -1, 0, 1 for less than, equal to, greater than; nil if not comparable
92
+ def <=>(other)
93
+ return nil unless other.is_a?(GameVersion)
94
+
95
+ to_a <=> other.to_a
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/events"
4
+ require "pathname"
5
+ require "tempfile"
6
+
7
+ module Factorix
8
+ module HTTP
9
+ # Adds caching for GET requests
10
+ #
11
+ # Stores successful GET responses in FileSystem cache.
12
+ # Only caches non-streaming requests (no block given).
13
+ class CacheDecorator
14
+ # @!parse
15
+ # # @return [HTTP::Client]
16
+ # attr_reader :client
17
+ # # @return [Cache::FileSystem]
18
+ # attr_reader :cache
19
+ # # @return [Dry::Logger::Dispatcher]
20
+ # attr_reader :logger
21
+ include Import[:client, :cache, :logger]
22
+ include Dry::Events::Publisher[:http]
23
+
24
+ register_event("cache.hit")
25
+ register_event("cache.miss")
26
+
27
+ # Execute an HTTP request (only caches GET without block)
28
+ #
29
+ # @param method [Symbol] HTTP method
30
+ # @param uri [URI::HTTPS] target URI
31
+ # @param headers [Hash<String, String>] request headers
32
+ # @param body [String, IO, nil] request body
33
+ # @yield [Net::HTTPResponse] for streaming responses
34
+ # @return [Response, Object] response object or parsed data
35
+ def request(method, uri, headers: {}, body: nil, &block)
36
+ if method == :get && !block
37
+ get(uri, headers:)
38
+ else
39
+ client.request(method, uri, headers:, body:, &block)
40
+ end
41
+ end
42
+
43
+ # Execute a GET request with caching
44
+ #
45
+ # @param uri [URI::HTTPS] target URI
46
+ # @param headers [Hash<String, String>] request headers
47
+ # @yield [Net::HTTPResponse] for streaming responses
48
+ # @return [Response, Object] response object or parsed data
49
+ def get(uri, headers: {}, &block)
50
+ # Don't cache streaming requests
51
+ return client.get(uri, headers:, &block) if block
52
+
53
+ key = cache.key_for(uri.to_s)
54
+
55
+ cached_body = cache.read(key)
56
+ if cached_body
57
+ logger.debug("Cache hit", uri: uri.to_s)
58
+ publish("cache.hit", url: uri.to_s)
59
+ return CachedResponse.new(cached_body)
60
+ end
61
+
62
+ logger.debug("Cache miss", uri: uri.to_s)
63
+ publish("cache.miss", url: uri.to_s)
64
+
65
+ # Locking prevents concurrent downloads of the same resource
66
+ cache.with_lock(key) do
67
+ # Double-check: another thread might have filled the cache
68
+ cached_body = cache.read(key)
69
+ if cached_body
70
+ publish("cache.hit", url: uri.to_s)
71
+ return CachedResponse.new(cached_body)
72
+ end
73
+
74
+ response = client.get(uri, headers:)
75
+
76
+ if response.success?
77
+ with_temporary_file do |temp|
78
+ temp.write(response.body)
79
+ temp.close
80
+ cache.store(key, Pathname(temp.path))
81
+ end
82
+ end
83
+
84
+ response
85
+ end
86
+ end
87
+
88
+ # Execute a POST request (never cached)
89
+ #
90
+ # @param uri [URI::HTTPS] target URI
91
+ # @param body [String, IO] request body
92
+ # @param headers [Hash<String, String>] request headers
93
+ # @param content_type [String, nil] Content-Type header
94
+ # @return [Response] response object
95
+ def post(uri, body:, headers: {}, content_type: nil) = client.post(uri, body:, headers:, content_type:)
96
+
97
+ private def with_temporary_file
98
+ temp_file = Tempfile.new("http_cache")
99
+ yield temp_file
100
+ ensure
101
+ temp_file&.close
102
+ temp_file&.unlink
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module HTTP
5
+ # Response wrapper for cached data
6
+ #
7
+ # Provides a simple response object that can be constructed from
8
+ # a cached body string, without requiring an actual HTTP response.
9
+ # Used by CacheDecorator to return cached content with a uniform
10
+ # interface matching Response objects.
11
+ class CachedResponse
12
+ attr_reader :body
13
+ attr_reader :code
14
+ attr_reader :headers
15
+
16
+ # @param body [String] cached response body
17
+ def initialize(body)
18
+ @body = body
19
+ @code = 200
20
+ @headers = {"content-type" => ["application/octet-stream"]}
21
+ end
22
+
23
+ # Always returns true for cached responses
24
+ #
25
+ # Since only successful responses are cached, all CachedResponse
26
+ # objects represent successful HTTP interactions.
27
+ #
28
+ # @return [Boolean] true
29
+ def success? = true
30
+
31
+ # Get content length from body size
32
+ #
33
+ # @return [Integer] body size in bytes
34
+ def content_length = @body.bytesize
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+
7
+ module Factorix
8
+ module HTTP
9
+ # Low-level HTTP client using Net::HTTP
10
+ #
11
+ # Responsibilities:
12
+ # - Create and configure Net::HTTP instances
13
+ # - Execute HTTP methods (GET, POST)
14
+ # - Handle redirects (up to MAX_REDIRECTS)
15
+ # - Parse response codes and raise appropriate errors
16
+ # - Stream reading/writing for large files
17
+ class Client
18
+ include Import[:logger]
19
+
20
+ MAX_REDIRECTS = 10
21
+ private_constant :MAX_REDIRECTS
22
+
23
+ # @return [Array<String>] URL parameter names to mask in logs
24
+ attr_reader :masked_params
25
+
26
+ # @param masked_params [Array<String>] URL parameter names to mask in logs
27
+ def initialize(masked_params: [], **)
28
+ super(**)
29
+ @masked_params = masked_params.freeze
30
+ end
31
+
32
+ # Execute an HTTP request
33
+ #
34
+ # @param method [Symbol] HTTP method (:get, :post, :put, :delete)
35
+ # @param uri [URI::HTTPS] target URI
36
+ # @param headers [Hash<String, String>] request headers
37
+ # @param body [String, IO, nil] request body
38
+ # @yield [Net::HTTPResponse] for streaming responses
39
+ # @return [Response] response object
40
+ # @raise [URLError] if URI is not HTTPS or too many redirects
41
+ # @raise [InvalidArgumentError] if HTTP method is unsupported
42
+ # @raise [HTTPNotFoundError] for 404 errors
43
+ # @raise [HTTPClientError] for 4xx errors
44
+ # @raise [HTTPServerError] for 5xx errors
45
+ # @raise [HTTPError] for other HTTP errors
46
+ def request(method, uri, headers: {}, body: nil, &)
47
+ raise URLError, "URL must be HTTPS" unless uri.is_a?(URI::HTTPS)
48
+
49
+ logger.info("HTTP request", method: method.upcase, url: mask_credentials(uri))
50
+ perform_request(method, uri, redirect_count: 0, headers:, body:, &)
51
+ end
52
+
53
+ # Execute a GET request
54
+ #
55
+ # @param uri [URI::HTTPS] target URI
56
+ # @param headers [Hash<String, String>] request headers
57
+ # @yield [Net::HTTPResponse] for streaming responses
58
+ # @return [Response] response object
59
+ def get(uri, headers: {}, &) = request(:get, uri, headers:, &)
60
+
61
+ # Execute a POST request
62
+ #
63
+ # @param uri [URI::HTTPS] target URI
64
+ # @param body [String, IO] request body
65
+ # @param headers [Hash<String, String>] request headers
66
+ # @param content_type [String, nil] Content-Type header
67
+ # @return [Response] response object
68
+ def post(uri, body:, headers: {}, content_type: nil)
69
+ headers = headers.merge("Content-Type" => content_type) if content_type
70
+ request(:post, uri, body:, headers:)
71
+ end
72
+
73
+ private def perform_request(method, uri, redirect_count:, headers:, body:, &block)
74
+ if redirect_count > MAX_REDIRECTS
75
+ logger.error("Too many redirects", redirect_count:)
76
+ raise URLError, "Too many redirects (#{redirect_count})"
77
+ end
78
+
79
+ http = create_http(uri)
80
+ req = build_request(method, uri, headers:, body:)
81
+
82
+ result = nil
83
+ http.request(req) do |response|
84
+ result = handle_response(response, method, uri, redirect_count, &block)
85
+ end
86
+ result
87
+ end
88
+
89
+ private def handle_response(response, _method, _uri, redirect_count, &block)
90
+ case response
91
+ when Net::HTTPSuccess, Net::HTTPPartialContent
92
+ yield(response) if block
93
+ Response.new(response)
94
+
95
+ when Net::HTTPRedirection
96
+ location = response["Location"]
97
+ redirect_url = URI(location)
98
+ logger.info("Following redirect", location: mask_credentials(redirect_url))
99
+
100
+ perform_request(:get, redirect_url, redirect_count: redirect_count + 1, headers: {}, body: nil, &block)
101
+
102
+ when Net::HTTPNotFound
103
+ api_error, api_message = parse_api_error(response)
104
+ logger.error("HTTP not found", code: response.code, message: response.message, api_message:)
105
+ raise HTTPNotFoundError.new("#{response.code} #{response.message}", api_error:, api_message:)
106
+
107
+ when Net::HTTPClientError
108
+ api_error, api_message = parse_api_error(response)
109
+ logger.error("HTTP client error", code: response.code, message: response.message, api_message:)
110
+ raise HTTPClientError.new("#{response.code} #{response.message}", api_error:, api_message:)
111
+
112
+ when Net::HTTPServerError
113
+ logger.error("HTTP server error", code: response.code, message: response.message)
114
+ raise HTTPServerError, "#{response.code} #{response.message}"
115
+
116
+ else
117
+ raise HTTPError, "#{response.code} #{response.message}"
118
+ end
119
+ rescue URI::InvalidURIError
120
+ raise HTTPError, "Invalid redirect URI: #{response["Location"]}"
121
+ end
122
+
123
+ # Parse API error response body for error and message fields
124
+ #
125
+ # @param response [Net::HTTPResponse] HTTP response
126
+ # @return [Array(String, String), Array(nil, nil)] tuple of [api_error, api_message]
127
+ private def parse_api_error(response)
128
+ return [nil, nil] unless response.content_type&.include?("application/json")
129
+
130
+ body = response.body
131
+ return [nil, nil] if body.nil? || body.empty?
132
+
133
+ json = JSON.parse(body, symbolize_names: true)
134
+ [json[:error], json[:message]]
135
+ rescue JSON::ParserError
136
+ [nil, nil]
137
+ end
138
+
139
+ private def build_request(method, uri, headers:, body:)
140
+ request = case method
141
+ when :get then Net::HTTP::Get.new(uri)
142
+ when :post then Net::HTTP::Post.new(uri)
143
+ when :put then Net::HTTP::Put.new(uri)
144
+ when :delete then Net::HTTP::Delete.new(uri)
145
+ else raise InvalidArgumentError, "Unsupported method: #{method}"
146
+ end
147
+
148
+ headers.each {|k, v| request[k] = v }
149
+
150
+ if body
151
+ if body.respond_to?(:read)
152
+ request.body_stream = body
153
+ else
154
+ request.body = body
155
+ end
156
+ end
157
+
158
+ request
159
+ end
160
+
161
+ private def create_http(uri)
162
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
163
+ http.use_ssl = uri.scheme == "https"
164
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
165
+ http.open_timeout = Application.config.http.connect_timeout
166
+ http.read_timeout = Application.config.http.read_timeout
167
+ http.write_timeout = Application.config.http.write_timeout if http.respond_to?(:write_timeout=)
168
+ end
169
+ end
170
+
171
+ # Mask sensitive URL parameters for logging
172
+ #
173
+ # @param url [URI] URL to mask
174
+ # @return [String] URL string with sensitive parameters masked
175
+ private def mask_credentials(url)
176
+ return url.to_s unless url.query
177
+ return url.to_s if masked_params.empty?
178
+
179
+ masked_url = url.dup
180
+ params = URI.decode_www_form(masked_url.query).to_h
181
+ masked_params.each {|key| params[key] = "*****" if params.key?(key) }
182
+ masked_url.query = URI.encode_www_form(params)
183
+ masked_url.to_s
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module HTTP
5
+ # Simple response wrapper for Net::HTTP responses
6
+ class Response
7
+ attr_reader :code
8
+ attr_reader :body
9
+ attr_reader :headers
10
+ attr_reader :raw_response
11
+
12
+ # @param net_http_response [Net::HTTPResponse] Raw Net::HTTP response
13
+ def initialize(net_http_response)
14
+ @code = Integer(net_http_response.code, 10)
15
+ @body = net_http_response.body
16
+ @headers = net_http_response.to_hash
17
+ @raw_response = net_http_response
18
+ end
19
+
20
+ # Check if response is successful (2xx)
21
+ #
22
+ # @return [Boolean] true if 2xx response
23
+ def success? = (200..299).cover?(@code)
24
+
25
+ # Get Content-Length from headers
26
+ #
27
+ # @return [Integer, nil] content length in bytes, or nil if not present
28
+ def content_length = Integer(@headers["content-length"]&.first, 10)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module HTTP
5
+ # Adds automatic retry with exponential backoff to HTTP client
6
+ #
7
+ # Wraps any HTTP client and retries requests on network errors
8
+ # using the configured RetryStrategy.
9
+ class RetryDecorator
10
+ # @!parse
11
+ # # @return [HTTP::Client]
12
+ # attr_reader :client
13
+ # # @return [HTTP::RetryStrategy]
14
+ # attr_reader :retry_strategy
15
+ # # @return [Dry::Logger::Dispatcher]
16
+ # attr_reader :logger
17
+ include Import[:client, :retry_strategy, :logger]
18
+
19
+ # Execute an HTTP request with retry
20
+ #
21
+ # @param method [Symbol] HTTP method
22
+ # @param uri [URI::HTTPS] target URI
23
+ # @param headers [Hash<String, String>] request headers
24
+ # @param body [String, IO, nil] request body
25
+ # @yield [Net::HTTPResponse] for streaming responses
26
+ # @return [Response] response object
27
+ def request(method, uri, headers: {}, body: nil, &block)
28
+ retry_strategy.with_retry do
29
+ client.request(method, uri, headers:, body:, &block)
30
+ end
31
+ end
32
+
33
+ # Execute a GET request with retry
34
+ #
35
+ # @param uri [URI::HTTPS] target URI
36
+ # @param headers [Hash<String, String>] request headers
37
+ # @yield [Net::HTTPResponse] for streaming responses
38
+ # @return [Response] response object
39
+ def get(uri, headers: {}, &block)
40
+ retry_strategy.with_retry do
41
+ client.get(uri, headers:, &block)
42
+ end
43
+ end
44
+
45
+ # Execute a POST request with retry
46
+ #
47
+ # @param uri [URI::HTTPS] target URI
48
+ # @param body [String, IO] request body
49
+ # @param headers [Hash<String, String>] request headers
50
+ # @param content_type [String, nil] Content-Type header
51
+ # @return [Response] response object
52
+ def post(uri, body:, headers: {}, content_type: nil)
53
+ retry_strategy.with_retry do
54
+ client.post(uri, body:, headers:, content_type:)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/protocol"
4
+ require "openssl"
5
+ require "retriable"
6
+
7
+ module Factorix
8
+ module HTTP
9
+ # Class that manages retry strategy with exponential backoff and randomization
10
+ class RetryStrategy
11
+ # @!parse
12
+ # # @return [Dry::Logger::Dispatcher]
13
+ # attr_reader :logger
14
+ include Import[:logger]
15
+
16
+ DEFAULT_OPTIONS = {
17
+ tries: 3, # Number of attempts (including the initial try)
18
+ base_interval: 1.0, # Start with 1 second
19
+ multiplier: 2.0, # Double the interval each time
20
+ rand_factor: 0.25, # Add randomization
21
+ on: [ # Exceptions to retry on
22
+ Errno::ETIMEDOUT,
23
+ Errno::ECONNRESET,
24
+ Errno::ECONNREFUSED,
25
+ Net::OpenTimeout,
26
+ Net::ReadTimeout,
27
+ SocketError,
28
+ OpenSSL::SSL::SSLError,
29
+ EOFError
30
+ ]
31
+ }.freeze
32
+ private_constant :DEFAULT_OPTIONS
33
+
34
+ # Initialize a new retry strategy with customizable options
35
+ #
36
+ # @param options [Hash] Options for retry behavior
37
+ # @option options [Integer] :tries Number of attempts (including the initial try)
38
+ # @option options [Float] :base_interval Initial interval between retries (seconds)
39
+ # @option options [Float] :multiplier Exponential backoff multiplier
40
+ # @option options [Float] :rand_factor Randomization factor
41
+ # @option options [Array<Class>] :on Exception classes to retry on
42
+ # @option options [Proc] :on_retry Callback called on each retry
43
+ def initialize(logger: nil, **options)
44
+ super(logger:)
45
+ @options = configure_options(options)
46
+ end
47
+
48
+ # Execute the block with automatic retry on specified exceptions.
49
+ # Uses exponential backoff with randomization for retry intervals
50
+ #
51
+ # @yield Block to execute
52
+ # @return [Object] Return value of the block
53
+ # @raise [StandardError] If the block fails after all retries
54
+ def with_retry(&) = Retriable.retriable(**@options, &)
55
+
56
+ # Configure retry options by merging with defaults and setting up callbacks
57
+ #
58
+ # @param options [Hash] User-provided options to merge with defaults
59
+ # @return [Hash] Complete set of configured options
60
+ private def configure_options(options)
61
+ result = DEFAULT_OPTIONS.merge(options)
62
+ unless result.key?(:on_retry)
63
+ result[:on_retry] = method(:default_retry_callback).to_proc
64
+ end
65
+ result
66
+ end
67
+
68
+ # Default callback for retry attempts that logs retry information
69
+ #
70
+ # @param exception [StandardError] The exception that triggered the retry
71
+ # @param try [Integer] The current retry attempt number
72
+ # @param elapsed_time [Float] Time elapsed since first attempt
73
+ # @param next_interval [Float] Time until next retry attempt
74
+ # @return [void]
75
+ private def default_retry_callback(exception, try, elapsed_time, next_interval)
76
+ logger.warn "Retry #{try} after #{elapsed_time}s, next in #{next_interval}s: #{exception.class} - #{exception.message}"
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tempfile"
5
+ require "zip"
6
+
7
+ module Factorix
8
+ InfoJSON = Data.define(:name, :version, :title, :author, :description, :factorio_version, :dependencies)
9
+
10
+ # Factorio MOD info.json representation
11
+ #
12
+ # Represents the metadata file that must be present in every Factorio MOD.
13
+ # Only required fields (name, version, title, author) are enforced.
14
+ #
15
+ # @see https://lua-api.factorio.com/latest/auxiliary/mod-structure.html
16
+ class InfoJSON
17
+ # Parse info.json from JSON string
18
+ #
19
+ # @param json_string [String] JSON content
20
+ # @return [InfoJSON] parsed info.json
21
+ # @raise [FileFormatError] if required fields are missing or JSON is invalid
22
+ def self.from_json(json_string)
23
+ data = JSON.parse(json_string)
24
+
25
+ required_fields = %w[name version title author]
26
+ missing = required_fields - data.keys
27
+ raise FileFormatError, "Missing required fields: #{missing.join(", ")}" unless missing.empty?
28
+
29
+ parser = Dependency::Parser.new
30
+ dependencies = (data["dependencies"] || []).map {|dep_str| parser.parse(dep_str) }
31
+
32
+ new(name: data["name"], version: MODVersion.from_string(data["version"]), title: data["title"], author: data["author"], description: data["description"] || "", factorio_version: data["factorio_version"], dependencies:)
33
+ rescue JSON::ParserError => e
34
+ raise FileFormatError, "Invalid JSON: #{e.message}"
35
+ rescue VersionParseError, DependencyParseError => e
36
+ raise FileFormatError, e.message
37
+ end
38
+
39
+ # Extract from zip file
40
+ #
41
+ # Uses caching to avoid repeated ZIP extraction for the same file.
42
+ # Cache key is based on file path (MOD ZIPs are immutable after download).
43
+ # Uses double-checked locking to prevent concurrent extraction while
44
+ # avoiding lock overhead on cache hits.
45
+ #
46
+ # @param zip_path [Pathname] path to MOD zip file
47
+ # @return [InfoJSON] parsed info.json from zip
48
+ # @raise [FileFormatError] if zip is invalid or info.json not found
49
+ def self.from_zip(zip_path)
50
+ cache = Application.resolve(:info_json_cache)
51
+ logger = Application.resolve(:logger)
52
+ cache_key = cache.key_for(zip_path.to_s)
53
+
54
+ if (cached_json = cache.read(cache_key, encoding: Encoding::UTF_8))
55
+ logger.debug("info.json cache hit", path: zip_path.to_s)
56
+ return from_json(cached_json)
57
+ end
58
+
59
+ logger.debug("info.json cache miss", path: zip_path.to_s)
60
+
61
+ cache.with_lock(cache_key) do
62
+ if (cached_json = cache.read(cache_key, encoding: Encoding::UTF_8))
63
+ logger.debug("info.json cache hit (after lock)", path: zip_path.to_s)
64
+ return from_json(cached_json)
65
+ end
66
+
67
+ json_string = Zip::File.open(zip_path) {|zip_file|
68
+ info_entry = zip_file.find {|entry| entry.name.end_with?("/info.json") }
69
+ raise FileFormatError, "info.json not found in #{zip_path}" unless info_entry
70
+
71
+ info_entry.get_input_stream.read
72
+ }
73
+
74
+ temp_file = Tempfile.new("info_json_cache")
75
+ begin
76
+ temp_file.write(json_string)
77
+ temp_file.close
78
+ cache.store(cache_key, Pathname(temp_file.path))
79
+ logger.debug("Stored info.json in cache", path: zip_path.to_s)
80
+ ensure
81
+ temp_file.unlink
82
+ end
83
+
84
+ from_json(json_string)
85
+ end
86
+ rescue Zip::Error => e
87
+ raise FileFormatError, "Invalid zip file: #{e.message}"
88
+ end
89
+ end
90
+ end