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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/completion/_factorix.bash +202 -0
- data/completion/_factorix.fish +197 -0
- data/completion/_factorix.zsh +376 -0
- data/doc/factorix.1 +377 -0
- data/exe/factorix +20 -0
- data/lib/factorix/api/category.rb +69 -0
- data/lib/factorix/api/image.rb +35 -0
- data/lib/factorix/api/license.rb +71 -0
- data/lib/factorix/api/mod_download_api.rb +66 -0
- data/lib/factorix/api/mod_info.rb +166 -0
- data/lib/factorix/api/mod_management_api.rb +237 -0
- data/lib/factorix/api/mod_portal_api.rb +204 -0
- data/lib/factorix/api/release.rb +49 -0
- data/lib/factorix/api/tag.rb +95 -0
- data/lib/factorix/api.rb +7 -0
- data/lib/factorix/api_credential.rb +54 -0
- data/lib/factorix/application.rb +218 -0
- data/lib/factorix/cache/file_system.rb +307 -0
- data/lib/factorix/cli/commands/backup_support.rb +46 -0
- data/lib/factorix/cli/commands/base.rb +90 -0
- data/lib/factorix/cli/commands/cache/evict.rb +180 -0
- data/lib/factorix/cli/commands/cache/stat.rb +201 -0
- data/lib/factorix/cli/commands/command_wrapper.rb +71 -0
- data/lib/factorix/cli/commands/completion.rb +83 -0
- data/lib/factorix/cli/commands/confirmable.rb +53 -0
- data/lib/factorix/cli/commands/download_support.rb +123 -0
- data/lib/factorix/cli/commands/launch.rb +79 -0
- data/lib/factorix/cli/commands/man.rb +29 -0
- data/lib/factorix/cli/commands/mod/check.rb +99 -0
- data/lib/factorix/cli/commands/mod/disable.rb +188 -0
- data/lib/factorix/cli/commands/mod/download.rb +291 -0
- data/lib/factorix/cli/commands/mod/edit.rb +114 -0
- data/lib/factorix/cli/commands/mod/enable.rb +216 -0
- data/lib/factorix/cli/commands/mod/image/add.rb +47 -0
- data/lib/factorix/cli/commands/mod/image/edit.rb +41 -0
- data/lib/factorix/cli/commands/mod/image/list.rb +74 -0
- data/lib/factorix/cli/commands/mod/install.rb +443 -0
- data/lib/factorix/cli/commands/mod/list.rb +372 -0
- data/lib/factorix/cli/commands/mod/search.rb +134 -0
- data/lib/factorix/cli/commands/mod/settings/dump.rb +88 -0
- data/lib/factorix/cli/commands/mod/settings/restore.rb +101 -0
- data/lib/factorix/cli/commands/mod/show.rb +202 -0
- data/lib/factorix/cli/commands/mod/sync.rb +299 -0
- data/lib/factorix/cli/commands/mod/uninstall.rb +325 -0
- data/lib/factorix/cli/commands/mod/update.rb +222 -0
- data/lib/factorix/cli/commands/mod/upload.rb +90 -0
- data/lib/factorix/cli/commands/path.rb +79 -0
- data/lib/factorix/cli/commands/requires_game_stopped.rb +32 -0
- data/lib/factorix/cli/commands/version.rb +25 -0
- data/lib/factorix/cli.rb +42 -0
- data/lib/factorix/dependency/edge.rb +89 -0
- data/lib/factorix/dependency/entry.rb +124 -0
- data/lib/factorix/dependency/graph/builder.rb +108 -0
- data/lib/factorix/dependency/graph.rb +210 -0
- data/lib/factorix/dependency/list.rb +244 -0
- data/lib/factorix/dependency/mod_version_requirement.rb +73 -0
- data/lib/factorix/dependency/node.rb +60 -0
- data/lib/factorix/dependency/parser.rb +148 -0
- data/lib/factorix/dependency/validation_result.rb +138 -0
- data/lib/factorix/dependency/validator.rb +190 -0
- data/lib/factorix/errors.rb +112 -0
- data/lib/factorix/formatting.rb +56 -0
- data/lib/factorix/game_version.rb +98 -0
- data/lib/factorix/http/cache_decorator.rb +106 -0
- data/lib/factorix/http/cached_response.rb +37 -0
- data/lib/factorix/http/client.rb +187 -0
- data/lib/factorix/http/response.rb +31 -0
- data/lib/factorix/http/retry_decorator.rb +59 -0
- data/lib/factorix/http/retry_strategy.rb +80 -0
- data/lib/factorix/info_json.rb +90 -0
- data/lib/factorix/installed_mod.rb +239 -0
- data/lib/factorix/mod.rb +55 -0
- data/lib/factorix/mod_list.rb +174 -0
- data/lib/factorix/mod_settings.rb +278 -0
- data/lib/factorix/mod_state.rb +34 -0
- data/lib/factorix/mod_version.rb +99 -0
- data/lib/factorix/portal.rb +185 -0
- data/lib/factorix/progress/download_handler.rb +46 -0
- data/lib/factorix/progress/multi_presenter.rb +45 -0
- data/lib/factorix/progress/presenter.rb +67 -0
- data/lib/factorix/progress/presenter_adapter.rb +46 -0
- data/lib/factorix/progress/scan_handler.rb +33 -0
- data/lib/factorix/progress/upload_handler.rb +33 -0
- data/lib/factorix/runtime/base.rb +233 -0
- data/lib/factorix/runtime/linux.rb +32 -0
- data/lib/factorix/runtime/mac_os.rb +53 -0
- data/lib/factorix/runtime/user_configurable.rb +69 -0
- data/lib/factorix/runtime/windows.rb +85 -0
- data/lib/factorix/runtime/wsl.rb +118 -0
- data/lib/factorix/runtime.rb +32 -0
- data/lib/factorix/save_file.rb +178 -0
- data/lib/factorix/ser_des/deserializer.rb +198 -0
- data/lib/factorix/ser_des/serializer.rb +231 -0
- data/lib/factorix/ser_des/signed_integer.rb +63 -0
- data/lib/factorix/ser_des/unsigned_integer.rb +65 -0
- data/lib/factorix/service_credential.rb +127 -0
- data/lib/factorix/transfer/downloader.rb +162 -0
- data/lib/factorix/transfer/uploader.rb +232 -0
- data/lib/factorix/version.rb +6 -0
- data/lib/factorix.rb +38 -0
- data/sig/dry/auto_inject.rbs +15 -0
- data/sig/dry/cli.rbs +19 -0
- data/sig/dry/configurable.rbs +13 -0
- data/sig/dry/core/container.rbs +17 -0
- data/sig/dry/events/publisher.rbs +22 -0
- data/sig/dry/logger.rbs +16 -0
- data/sig/factorix/api/category.rbs +15 -0
- data/sig/factorix/api/image.rbs +15 -0
- data/sig/factorix/api/license.rbs +20 -0
- data/sig/factorix/api/mod_download_api.rbs +18 -0
- data/sig/factorix/api/mod_info.rbs +67 -0
- data/sig/factorix/api/mod_management_api.rbs +25 -0
- data/sig/factorix/api/mod_portal_api.rbs +31 -0
- data/sig/factorix/api/release.rbs +27 -0
- data/sig/factorix/api/tag.rbs +15 -0
- data/sig/factorix/api.rbs +8 -0
- data/sig/factorix/api_credential.rbs +17 -0
- data/sig/factorix/application.rbs +86 -0
- data/sig/factorix/cache/file_system.rbs +35 -0
- data/sig/factorix/cli/commands/base.rbs +13 -0
- data/sig/factorix/cli/commands/cache/evict.rbs +17 -0
- data/sig/factorix/cli/commands/cache/stat.rbs +17 -0
- data/sig/factorix/cli/commands/command_wrapper.rbs +13 -0
- data/sig/factorix/cli/commands/completion/zsh.rbs +15 -0
- data/sig/factorix/cli/commands/confirmable.rbs +12 -0
- data/sig/factorix/cli/commands/download_support.rbs +12 -0
- data/sig/factorix/cli/commands/launch.rbs +15 -0
- data/sig/factorix/cli/commands/mod/check.rbs +18 -0
- data/sig/factorix/cli/commands/mod/disable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/download.rbs +18 -0
- data/sig/factorix/cli/commands/mod/edit.rbs +30 -0
- data/sig/factorix/cli/commands/mod/enable.rbs +20 -0
- data/sig/factorix/cli/commands/mod/image/add.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/edit.rbs +19 -0
- data/sig/factorix/cli/commands/mod/image/list.rbs +19 -0
- data/sig/factorix/cli/commands/mod/install.rbs +19 -0
- data/sig/factorix/cli/commands/mod/list.rbs +30 -0
- data/sig/factorix/cli/commands/mod/search.rbs +18 -0
- data/sig/factorix/cli/commands/mod/settings/dump.rbs +17 -0
- data/sig/factorix/cli/commands/mod/settings/restore.rbs +17 -0
- data/sig/factorix/cli/commands/mod/sync.rbs +19 -0
- data/sig/factorix/cli/commands/mod/uninstall.rbs +20 -0
- data/sig/factorix/cli/commands/mod/update.rbs +19 -0
- data/sig/factorix/cli/commands/mod/upload.rbs +24 -0
- data/sig/factorix/cli/commands/path.rbs +18 -0
- data/sig/factorix/cli/commands/requires_game_stopped.rbs +13 -0
- data/sig/factorix/cli/commands/version.rbs +13 -0
- data/sig/factorix/cli.rbs +11 -0
- data/sig/factorix/dependency/edge.rbs +32 -0
- data/sig/factorix/dependency/entry.rbs +30 -0
- data/sig/factorix/dependency/graph/builder.rbs +17 -0
- data/sig/factorix/dependency/graph.rbs +39 -0
- data/sig/factorix/dependency/list.rbs +69 -0
- data/sig/factorix/dependency/mod_version_requirement.rbs +18 -0
- data/sig/factorix/dependency/node.rbs +24 -0
- data/sig/factorix/dependency/parser.rbs +11 -0
- data/sig/factorix/dependency/validation_result.rbs +56 -0
- data/sig/factorix/dependency/validator.rbs +13 -0
- data/sig/factorix/errors.rbs +132 -0
- data/sig/factorix/formatting.rbs +8 -0
- data/sig/factorix/game_version.rbs +24 -0
- data/sig/factorix/http/cache_decorator.rbs +64 -0
- data/sig/factorix/http/client.rbs +55 -0
- data/sig/factorix/http/response.rbs +28 -0
- data/sig/factorix/http/retry_decorator.rbs +44 -0
- data/sig/factorix/http/retry_strategy.rbs +42 -0
- data/sig/factorix/info_json.rbs +19 -0
- data/sig/factorix/installed_mod.rbs +34 -0
- data/sig/factorix/mod.rbs +20 -0
- data/sig/factorix/mod_list.rbs +44 -0
- data/sig/factorix/mod_settings.rbs +47 -0
- data/sig/factorix/mod_state.rbs +18 -0
- data/sig/factorix/mod_version.rbs +23 -0
- data/sig/factorix/portal.rbs +37 -0
- data/sig/factorix/progress/download_handler.rbs +19 -0
- data/sig/factorix/progress/multi_presenter.rbs +15 -0
- data/sig/factorix/progress/presenter.rbs +17 -0
- data/sig/factorix/progress/presenter_adapter.rbs +17 -0
- data/sig/factorix/progress/scan_handler.rbs +16 -0
- data/sig/factorix/progress/upload_handler.rbs +17 -0
- data/sig/factorix/runtime/base.rbs +45 -0
- data/sig/factorix/runtime/linux.rbs +15 -0
- data/sig/factorix/runtime/mac_os.rbs +15 -0
- data/sig/factorix/runtime/user_configurable.rbs +13 -0
- data/sig/factorix/runtime/windows.rbs +23 -0
- data/sig/factorix/runtime/wsl.rbs +19 -0
- data/sig/factorix/runtime.rbs +9 -0
- data/sig/factorix/save_file.rbs +40 -0
- data/sig/factorix/ser_des/deserializer.rbs +49 -0
- data/sig/factorix/ser_des/serializer.rbs +45 -0
- data/sig/factorix/ser_des/signed_integer.rbs +37 -0
- data/sig/factorix/ser_des/unsigned_integer.rbs +37 -0
- data/sig/factorix/service_credential.rbs +19 -0
- data/sig/factorix/transfer/downloader.rbs +15 -0
- data/sig/factorix/transfer/uploader.rbs +21 -0
- data/sig/factorix.rbs +9 -0
- data/sig/tty/progressbar.rbs +18 -0
- 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
|