my-s3-client 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e31328cf2415286256d1337973342420274b36f67447251568f2ea07a063da53
4
+ data.tar.gz: 5d859629f72a9f0aa8cbdf96902805dbaa66d0bd2bb4ef358939b384a07c630e
5
+ SHA512:
6
+ metadata.gz: 47de899d847f96edddf1f148a3e6160b6c0350164d044918f0bc3fc1a51e33d3b4a6d9b01c358fb63ee8e1715f075f04a6facd60fd16070becba81027b3f7674
7
+ data.tar.gz: b43571317be9a2c841517139de2805419bf953b7ec29f8ecab43d1906240c8fdfbaedccaa553c7b0c3a4d2d726148d9c33938be75c1aad39ed7bd2d935e6b763
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leandro Sardi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # My.S3 Client
2
+
3
+ Ruby client library for [My.S3](https://github.com/leandrosardi/my.s3) — the filesystem-backed object store. The gem adds ergonomic helpers around the official HTTP API and keeps dependencies minimal so it can run anywhere Ruby and Net::HTTP are available.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bundle add my-s3-client
9
+ # or
10
+ # gem install my-s3-client
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```ruby
16
+ require 'my_s3/client'
17
+
18
+ client = MyS3::Client.new(
19
+ base_url: 'https://storage.example.com',
20
+ api_key: ENV.fetch('MY_S3_API_KEY')
21
+ )
22
+
23
+ client.ensure_folder_chain('channels/12345/assets')
24
+ client.upload_file(
25
+ file_path: '/tmp/avatar.png',
26
+ path: 'channels/12345/assets',
27
+ filename: 'avatar.png'
28
+ )
29
+
30
+ listing = client.list(path: 'channels/12345')
31
+ public_url = client.get_public_url(path: 'channels/12345/assets', filename: 'avatar.png')['public_url']
32
+ client.download_file(path: 'channels/12345/assets', filename: 'avatar.png', target_path: 'avatar-copy.png')
33
+ ```
34
+
35
+ ## Covered Endpoints
36
+
37
+ Each method returns the parsed JSON payload emitted by My.S3 (normally `{ 'success' => true, ... }`). Errors raise `MyS3::Client::Error`.
38
+
39
+ | Method | Description |
40
+ | --- | --- |
41
+ | `list(path: '')` | `GET /list.json` to enumerate folders/files. |
42
+ | `create_folder(path:, folder_name:)` | `POST /create_folder.json`. |
43
+ | `delete_folder(path:)` | `DELETE /delete_folder.json`. |
44
+ | `rename_folder(path:, new_name:)` | `POST /rename_folder.json`. |
45
+ | `upload_file(file_path:, path: '', filename: nil, ensure_path: true)` | Multipart `POST /upload.json` with automatic folder creation. |
46
+ | `delete_file(path:, filename:)` | `DELETE /delete.json`. |
47
+ | `delete_older_than(path:, older_than:)` | `POST /delete_older_than.json`. |
48
+ | `get_download_url(path:, filename:)` | `POST /get_download_url.json`. |
49
+ | `get_public_url(path:, filename:)` | `POST /get_public_url.json`. |
50
+ | `download_file(path:, filename:, target_path: nil)` | Streams the anonymous file endpoint (`GET /:path/:filename`). |
51
+ | `ensure_folder_chain(path)` | Helper that recursively creates folders via repeated `create_folder` calls. |
52
+
53
+ ## Configuration Tips
54
+
55
+ - `base_url` should point to the HTTP endpoint exposed by your My.S3 node; a trailing slash is optional.
56
+ - `api_key` must match the `api_key` configured on the server.
57
+ - Adjust `open_timeout` and `read_timeout` (constructor options) for slower networks or large uploads.
58
+ - Combine this gem with background jobs or CLI scripts to automate ingestion, backups, or content rendering pipelines.
59
+
60
+ ## Development
61
+
62
+ ```bash
63
+ bundle install
64
+ # add specs under test/ or spec/ and run them via your preferred test runner
65
+ ```
66
+
67
+ Bug reports and pull requests are welcome.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyS3
4
+ class Client
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'securerandom'
6
+ require 'uri'
7
+
8
+ require_relative 'client/version'
9
+
10
+ module MyS3
11
+ class Client
12
+ class Error < StandardError; end
13
+
14
+ attr_reader :base_url, :api_key
15
+
16
+ def initialize(base_url:, api_key:, open_timeout: 10, read_timeout: 60)
17
+ @base_url = normalize_base_url(base_url)
18
+ @api_key = api_key.to_s.strip
19
+ raise Error, 'api_key is required' if @api_key.empty?
20
+
21
+ @open_timeout = Integer(open_timeout)
22
+ @read_timeout = Integer(read_timeout)
23
+ end
24
+
25
+ # ----------------------
26
+ # High-level API methods
27
+ # ----------------------
28
+
29
+ def list(path: '')
30
+ get_json('/list.json', path: path.to_s)
31
+ end
32
+
33
+ def create_folder(path:, folder_name:)
34
+ post_json('/create_folder.json', path: path.to_s, folder_name: folder_name.to_s)
35
+ end
36
+
37
+ def delete_folder(path:)
38
+ delete_json('/delete_folder.json', path: path.to_s)
39
+ end
40
+
41
+ def rename_folder(path:, new_name:)
42
+ post_json('/rename_folder.json', path: path.to_s, new_name: new_name.to_s)
43
+ end
44
+
45
+ def delete_file(path:, filename:)
46
+ delete_json('/delete.json', path: path.to_s, filename: filename.to_s)
47
+ end
48
+
49
+ def delete_older_than(path:, older_than:)
50
+ post_json('/delete_older_than.json', path: path.to_s, older_than: older_than)
51
+ end
52
+
53
+ def get_download_url(path:, filename:)
54
+ post_json('/get_download_url.json', path: path.to_s, filename: filename.to_s)
55
+ end
56
+
57
+ def get_public_url(path:, filename:)
58
+ post_json('/get_public_url.json', path: path.to_s, filename: filename.to_s)
59
+ end
60
+
61
+ def upload_file(file_path:, path: '', filename: nil, ensure_path: true)
62
+ raise Error, 'file_path is required' if file_path.to_s.strip.empty?
63
+ raise Error, "File not found: #{file_path}" unless File.file?(file_path)
64
+
65
+ relative_path = path.to_s
66
+ filename ||= File.basename(file_path)
67
+
68
+ ensure_folder_chain(relative_path) if ensure_path
69
+
70
+ boundary = "----MyS3Client#{SecureRandom.hex(12)}"
71
+ uri = uri_for('/upload.json')
72
+ request = Net::HTTP::Post.new(uri)
73
+ request['X-API-Key'] = api_key
74
+ request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
75
+ request.body = build_my_s3_multipart(boundary, relative_path, filename, file_path)
76
+
77
+ response = http_request(uri, request)
78
+ json = parse_json(response.body)
79
+ return json if response.is_a?(Net::HTTPSuccess) && json['success']
80
+
81
+ message = json.dig('error', 'message') || response.body
82
+ raise Error, message
83
+ end
84
+
85
+ def download_file(path:, filename:, target_path: nil)
86
+ relative = join_relative_path(path, filename)
87
+ uri = URI.join(base_url, relative)
88
+ request = Net::HTTP::Get.new(uri)
89
+ response = http_request(uri, request, include_api_key: false)
90
+
91
+ unless response.is_a?(Net::HTTPSuccess)
92
+ raise Error, "Failed to download file: #{response.code} #{response.message}"
93
+ end
94
+
95
+ if target_path
96
+ File.binwrite(target_path, response.body)
97
+ target_path
98
+ else
99
+ response.body
100
+ end
101
+ end
102
+
103
+ def ensure_folder_chain(path)
104
+ sanitized = normalize_relative_path(path)
105
+ return true if sanitized.empty?
106
+
107
+ current = ''
108
+ sanitized.split('/').each do |segment|
109
+ begin
110
+ create_folder(path: current, folder_name: segment)
111
+ rescue Error => e
112
+ raise unless e.message =~ /already exists/i
113
+ end
114
+ current = current.empty? ? segment : [current, segment].join('/')
115
+ end
116
+
117
+ true
118
+ end
119
+
120
+ # ----------------------
121
+ # HTTP helper methods
122
+ # ----------------------
123
+
124
+ def get_json(endpoint, params = {})
125
+ uri = uri_for(endpoint, params: params)
126
+ request = Net::HTTP::Get.new(uri)
127
+ request['X-API-Key'] = api_key
128
+ request_json(uri, request)
129
+ end
130
+
131
+ def post_json(endpoint, payload = {})
132
+ uri = uri_for(endpoint)
133
+ request = Net::HTTP::Post.new(uri)
134
+ request['Content-Type'] = 'application/json'
135
+ request['X-API-Key'] = api_key
136
+ request.body = JSON.generate(payload)
137
+ request_json(uri, request)
138
+ end
139
+
140
+ def delete_json(endpoint, payload = {})
141
+ uri = uri_for(endpoint)
142
+ request = Net::HTTP::Delete.new(uri)
143
+ request['Content-Type'] = 'application/json'
144
+ request['X-API-Key'] = api_key
145
+ request.body = JSON.generate(payload)
146
+ request_json(uri, request)
147
+ end
148
+
149
+ private
150
+
151
+ def request_json(uri, request)
152
+ response = http_request(uri, request)
153
+ json = parse_json(response.body)
154
+ return json if response.is_a?(Net::HTTPSuccess) && json['success']
155
+
156
+ message = json.dig('error', 'message') || response.body
157
+ raise Error, message
158
+ end
159
+
160
+ def http_request(uri, request, include_api_key: true)
161
+ request['X-API-Key'] = api_key if include_api_key && !request['X-API-Key']
162
+ http = Net::HTTP.new(uri.host, uri.port)
163
+ http.use_ssl = uri.scheme == 'https'
164
+ http.open_timeout = @open_timeout
165
+ http.read_timeout = @read_timeout
166
+ http.request(request)
167
+ rescue Timeout::Error => e
168
+ raise Error, "MyS3 request timed out: #{e.message}"
169
+ rescue SocketError, Errno::ECONNREFUSED => e
170
+ raise Error, "MyS3 connection failed: #{e.message}"
171
+ end
172
+
173
+ def parse_json(body)
174
+ return {} if body.nil? || body.strip.empty?
175
+ JSON.parse(body)
176
+ rescue JSON::ParserError
177
+ raise Error, "Invalid JSON response: #{body}"
178
+ end
179
+
180
+ def build_my_s3_multipart(boundary, relative_path, filename, local_path)
181
+ body = []
182
+ body << "--#{boundary}\r\n"
183
+ body << "Content-Disposition: form-data; name=\"path\"\r\n\r\n"
184
+ body << "#{relative_path}\r\n"
185
+ body << "--#{boundary}\r\n"
186
+ body << "Content-Disposition: form-data; name=\"file\"; filename=\"#{filename}\"\r\n"
187
+ body << "Content-Type: application/octet-stream\r\n\r\n"
188
+ body << File.binread(local_path)
189
+ body << "\r\n--#{boundary}--\r\n"
190
+ body.join
191
+ end
192
+
193
+ def uri_for(endpoint, params: nil)
194
+ path = endpoint.to_s.sub(%r{^/+}, '')
195
+ uri = URI.join(base_url, path)
196
+ if params && !params.empty?
197
+ query = URI.encode_www_form(params.transform_values { |v| v.nil? ? '' : v })
198
+ uri.query = query
199
+ end
200
+ uri
201
+ end
202
+
203
+ def normalize_base_url(url)
204
+ value = url.to_s.strip
205
+ raise Error, 'base_url is required' if value.empty?
206
+ value.end_with?('/') ? value : "#{value}/"
207
+ end
208
+
209
+ def normalize_relative_path(path)
210
+ value = path.to_s.strip
211
+ return '' if value.empty?
212
+ value = value.gsub(%r{^/+}, '').gsub(%r{/+$}, '')
213
+ value
214
+ end
215
+
216
+ def join_relative_path(path, filename)
217
+ fragments = [normalize_relative_path(path), filename.to_s]
218
+ fragments.reject!(&:empty?)
219
+ fragments.join('/')
220
+ end
221
+ end
222
+ end
metadata ADDED
@@ -0,0 +1,93 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: my-s3-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Leandro D. Sardi
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-04 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: net-http
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.2.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: json
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '2.6'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '2.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: uri
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0.11'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0.11'
55
+ description: Simple, dependency-light client for talking to a My.S3 server via its
56
+ JSON and multipart HTTP API.
57
+ email:
58
+ - leandro@massprospecting.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - LICENSE
64
+ - README.md
65
+ - lib/my_s3/client.rb
66
+ - lib/my_s3/client/version.rb
67
+ homepage: https://github.com/leandrosardi/my.s3
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ homepage_uri: https://github.com/leandrosardi/my.s3
72
+ source_code_uri: https://github.com/leandrosardi/my.s3-client
73
+ changelog_uri: https://github.com/leandrosardi/my.s3-client
74
+ post_install_message:
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 3.3.7
90
+ signing_key:
91
+ specification_version: 4
92
+ summary: Ruby client for the My.S3 object storage service.
93
+ test_files: []