feat(minio): initial stable release v1.0.0 — Client interface, xerrors, GetObject, doc examples
Add Client interface with PutObject, GetObject, RemoveObject, PresignedGetObject, and HandleError; Component now embeds Client with Native() as escape hatch for operations not covered by the interface. Add xerrors dependency: HandleError maps minio-go error codes to portable typed codes (NoSuchKey → ErrNotFound, AccessDenied → ErrPermissionDenied, BucketAlreadyExists → ErrAlreadyExists, etc.). OnStop sets c.mc = nil for lifecycle consistency. doc.go updated with launcher wiring, upload, presigned URL, GetObject, and HandleError usage examples. API committed as stable. What's included: - Config with Endpoint, AccessKey, SecretKey, UseSSL, Bucket, Region (env-driven, embeddable) - Transport field in Config for test injection (env:"-", nil uses minio-go default) - Client interface: PutObject, GetObject, RemoveObject, PresignedGetObject, HandleError - Component interface: launcher.Component + health.Checkable + Client + Native() - New(logger, cfg) constructor for lifecycle registration via lc.Append - Automatic bucket creation on OnStart if bucket does not exist - Health check via BucketExists at LevelCritical priority - HandleError: maps minio-go ErrorResponse codes to xerrors typed errors - 21 unit tests using mock HTTP transport; no live server required
This commit is contained in:
1
.gitea/CODEOWNERS
Normal file
1
.gitea/CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
||||
* @go/CoreDevelopers @go/Agents
|
||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with go test -c
|
||||
*.test
|
||||
|
||||
# Output of go build
|
||||
*.out
|
||||
|
||||
# Dependency directory
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Editor / IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# VCS files
|
||||
COMMIT.md
|
||||
RELEASE.md
|
||||
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this module will be documented in this file.
|
||||
|
||||
## [v0.9.0] — 2026-05-20
|
||||
|
||||
### Added
|
||||
|
||||
- `Config` — env-tagged, embeddable struct: `Endpoint`, `AccessKey`, `SecretKey`, `UseSSL`, `Bucket`, `Region`, `Transport`
|
||||
- `Provider` interface — `Native() *minio.Client`
|
||||
- `Component` interface — `launcher.Component + health.Checkable + Provider`
|
||||
- `New(logger logz.Logger, cfg Config) Component` — constructor
|
||||
- Automatic bucket creation on `OnStart` if the configured bucket does not exist
|
||||
- Health check via `BucketExists` at `health.LevelCritical` priority
|
||||
- `OnStop` is a no-op — minio-go client is stateless
|
||||
107
CLAUDE.md
Normal file
107
CLAUDE.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# minio
|
||||
|
||||
MinIO (S3-compatible) client with launcher lifecycle and health check integration.
|
||||
|
||||
## Purpose
|
||||
|
||||
Manages the lifecycle of a `minio-go` SDK client: constructs it from config, verifies
|
||||
bucket existence at startup (creating the bucket if absent), exposes the native SDK client
|
||||
to consumers, and provides a health check via `BucketExists`. Does not wrap SDK operations —
|
||||
callers use the full minio-go API directly via `Native()`.
|
||||
|
||||
## Tier & Dependencies
|
||||
|
||||
**Tier 3 (infrastructure)** — depends on:
|
||||
- `code.nochebuena.dev/go/health` (Tier 2)
|
||||
- `code.nochebuena.dev/go/launcher` (Tier 2)
|
||||
- `code.nochebuena.dev/go/logz` (Tier 1)
|
||||
- `github.com/minio/minio-go/v7` (native MinIO client)
|
||||
|
||||
Does **not** depend on `xerrors` — errors from the minio-go client are wrapped with `fmt.Errorf`
|
||||
and returned as-is.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Native client exposure**: `Native() *miniogo.Client` returns the raw minio-go SDK client.
|
||||
No wrapper, no operation subset. Callers use `PutObject`, `RemoveObject`, `PresignedGetObject`,
|
||||
and all other SDK methods directly.
|
||||
|
||||
- **Single bucket per component**: `Config.Bucket` is used only for health checks and the
|
||||
startup init hook. Callers needing multiple buckets instantiate multiple `Component` values
|
||||
with different configs. Object key namespacing (e.g. `products/{id}/{uuid}.ext`) handles
|
||||
logical separation within a single bucket — no multi-bucket API is needed.
|
||||
|
||||
- **Bucket auto-creation**: If `Config.Bucket` does not exist when `OnStart` runs, the
|
||||
component creates it automatically and logs the event. Failure to create the bucket is a
|
||||
hard startup error — the application will not start.
|
||||
|
||||
- **Region defaults to `us-east-1`**: Self-hosted MinIO uses this region by default. Setting
|
||||
a non-empty region in `Options.Region` bypasses per-request region detection in minio-go,
|
||||
which avoids an extra `GET /<bucket>?location` HTTP call on every bucket operation.
|
||||
|
||||
- **Stateless client — `OnStop` is a no-op**: minio-go holds no persistent connections; there
|
||||
is nothing to close on shutdown.
|
||||
|
||||
- **Health priority is Critical**: `Priority()` returns `health.LevelCritical`. Without MinIO,
|
||||
object uploads and retrievals fail completely — there is no graceful degradation path.
|
||||
|
||||
- **`Transport` field for testing**: `Config.Transport http.RoundTripper` (tagged `env:"-"`)
|
||||
lets tests inject a mock transport without a live server. Setting it to `nil` in production
|
||||
uses the minio-go default transport.
|
||||
|
||||
## Patterns
|
||||
|
||||
**Lifecycle registration:**
|
||||
```go
|
||||
mc := minio.New(logger, cfg)
|
||||
lc.Append(mc)
|
||||
r.Get("/health", health.NewHandler(logger, mc))
|
||||
```
|
||||
|
||||
**Uploading an object:**
|
||||
```go
|
||||
_, err := provider.Native().PutObject(ctx, cfg.Bucket, key, reader, size,
|
||||
miniogo.PutObjectOptions{ContentType: "image/jpeg"})
|
||||
```
|
||||
|
||||
**Generating a presigned URL:**
|
||||
```go
|
||||
url, err := provider.Native().PresignedGetObject(ctx, cfg.Bucket, key, time.Hour, nil)
|
||||
```
|
||||
|
||||
**Injecting into a repository:**
|
||||
```go
|
||||
type imageRepo struct {
|
||||
provider minio.Provider
|
||||
bucket string
|
||||
}
|
||||
|
||||
func (r *imageRepo) Upload(ctx context.Context, key string, rd io.Reader, size int64) error {
|
||||
_, err := r.provider.Native().PutObject(ctx, r.bucket, key,
|
||||
rd, size, miniogo.PutObjectOptions{})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
## What to Avoid
|
||||
|
||||
- Do not wrap minio-go operations in this module. Keep `PutObject`, `RemoveObject`, etc. in
|
||||
the caller (repository layer). This module is a lifecycle building block, not a domain repository.
|
||||
- Do not define interfaces that subset minio-go methods here. If a consumer needs a minimal
|
||||
testable interface, define it in the consumer package.
|
||||
- Do not call `Native()` before `OnInit` has run — it returns `nil`.
|
||||
- Do not add a `Region` auto-detection request in tests — use `Config.Region = "us-east-1"` to
|
||||
bypass the `GET /<bucket>?location` lookup.
|
||||
|
||||
## Testing Notes
|
||||
|
||||
- Unit tests (`minio_test.go`) use an injected `mockTransport` via `Config.Transport`. No live
|
||||
MinIO server is required.
|
||||
- `Config.Region = "us-east-1"` must be set in test configs — it prevents minio-go from making
|
||||
an extra region detection request that the mock transport would not handle.
|
||||
- `TestComponent_OnStart_BucketError` and `TestComponent_HealthCheck_Unreachable` take ~4 seconds
|
||||
each due to minio-go's built-in retry backoff on errors. This is expected and correct behavior.
|
||||
- `compliance_test.go` (package `minio_test`) asserts `New(...)` satisfies `Component`
|
||||
at compile time.
|
||||
- Integration tests requiring a live MinIO instance belong in a higher-tier test suite (e.g.
|
||||
the consuming application's integration test suite).
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 NOCHEBUENADEV
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
102
README.md
Normal file
102
README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# minio
|
||||
|
||||
MinIO (S3-compatible) client with [go-kit](https://code.nochebuena.dev/go) launcher lifecycle and health check integration.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
go get code.nochebuena.dev/go/minio
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
miniomw "code.nochebuena.dev/go/minio"
|
||||
"code.nochebuena.dev/go/health"
|
||||
"code.nochebuena.dev/go/launcher"
|
||||
"code.nochebuena.dev/go/logz"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := logz.New(logz.Options{})
|
||||
|
||||
cfg := miniomw.Config{
|
||||
Endpoint: "minio:9000",
|
||||
AccessKey: "minioadmin",
|
||||
SecretKey: "minioadmin",
|
||||
UseSSL: false,
|
||||
Bucket: "my-app-assets",
|
||||
Region: "us-east-1",
|
||||
}
|
||||
|
||||
mc := miniomw.New(logger, cfg)
|
||||
|
||||
lc := launcher.New(logger)
|
||||
lc.Append(mc)
|
||||
|
||||
lc.BeforeStart(func() error {
|
||||
// Register health check
|
||||
_ = health.NewHandler(logger, mc)
|
||||
|
||||
// Use the native minio-go client for direct SDK operations
|
||||
native := mc.Native()
|
||||
_ = native // PutObject, RemoveObject, PresignedGetObject, etc.
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := lc.Run(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Config environment variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|-------------------|----------|--------------|----------------------------------------------|
|
||||
| `MINIO_ENDPOINT` | Yes | — | MinIO API host and port, e.g. `minio:9000` |
|
||||
| `MINIO_ACCESS_KEY`| Yes | — | Access key (username) |
|
||||
| `MINIO_SECRET_KEY`| Yes | — | Secret key (password) |
|
||||
| `MINIO_BUCKET` | Yes | — | Default bucket; created at startup if absent |
|
||||
| `MINIO_USE_SSL` | No | `false` | Enable TLS for the connection |
|
||||
| `MINIO_REGION` | No | `us-east-1` | Region; default covers all self-hosted MinIO |
|
||||
|
||||
## Lifecycle
|
||||
|
||||
| Hook | Behaviour |
|
||||
|------------|------------------------------------------------------------------|
|
||||
| `OnInit` | Constructs the minio-go SDK client; no network call |
|
||||
| `OnStart` | Checks whether the configured bucket exists; creates it if not |
|
||||
| `OnStop` | No-op — the minio-go client is stateless |
|
||||
|
||||
## Health check
|
||||
|
||||
`HealthCheck(ctx)` calls `BucketExists` on the configured bucket. Priority is `health.LevelCritical`.
|
||||
|
||||
## Multiple buckets
|
||||
|
||||
Each `Component` is bound to one bucket for health checks and startup init. For multiple buckets, instantiate multiple components with different configs. Use object key namespacing (e.g. `products/{id}/{uuid}.ext`) for logical separation within a single bucket.
|
||||
|
||||
## Direct SDK access
|
||||
|
||||
`Native()` returns the underlying `*minio.Client` from `github.com/minio/minio-go/v7`. Use it for all object operations:
|
||||
|
||||
```go
|
||||
native := mc.Native()
|
||||
|
||||
// Upload
|
||||
_, err := native.PutObject(ctx, "my-bucket", "products/abc/img.jpg", reader, size,
|
||||
minio.PutObjectOptions{ContentType: "image/jpeg"})
|
||||
|
||||
// Delete
|
||||
err = native.RemoveObject(ctx, "my-bucket", "products/abc/img.jpg",
|
||||
minio.RemoveObjectOptions{})
|
||||
|
||||
// Presigned URL (e.g. 1 hour)
|
||||
url, err := native.PresignedGetObject(ctx, "my-bucket", "products/abc/img.jpg",
|
||||
time.Hour, nil)
|
||||
```
|
||||
9
compliance_test.go
Normal file
9
compliance_test.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package minio_test
|
||||
|
||||
import (
|
||||
"code.nochebuena.dev/go/logz"
|
||||
"code.nochebuena.dev/go/minio"
|
||||
)
|
||||
|
||||
// Compile-time check: New returns a valid Component.
|
||||
var _ minio.Component = minio.New(logz.New(logz.Options{}), minio.Config{})
|
||||
46
doc.go
Normal file
46
doc.go
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Package minio provides a MinIO (S3-compatible) client with launcher lifecycle
|
||||
and health check integration.
|
||||
|
||||
Register the component with the launcher and use the Client interface in repositories:
|
||||
|
||||
cfg := minio.Config{
|
||||
Endpoint: "minio:9000",
|
||||
AccessKey: "minioadmin",
|
||||
SecretKey: "minioadmin",
|
||||
Bucket: "my-app-assets",
|
||||
}
|
||||
mc := minio.New(logger, cfg)
|
||||
lc.Append(mc) // OnInit + OnStart + OnStop managed automatically
|
||||
health.NewHandler(logger, mc) // mc satisfies health.Checkable
|
||||
|
||||
Uploading an object via the Client interface:
|
||||
|
||||
info, err := mc.PutObject(ctx, "my-app-assets", "products/abc/photo.jpg",
|
||||
reader, size, miniogo.PutObjectOptions{ContentType: "image/jpeg"})
|
||||
|
||||
Generating a pre-signed download URL:
|
||||
|
||||
u, err := mc.PresignedGetObject(ctx, "my-app-assets", "products/abc/photo.jpg", time.Hour, nil)
|
||||
|
||||
Use Native() for operations not covered by Client (e.g. PresignedPutObject, ListObjects):
|
||||
|
||||
url, err := mc.Native().PresignedPutObject(ctx, "my-app-assets", "products/abc/photo.jpg", time.Hour)
|
||||
|
||||
Errors returned by Client methods are already mapped to typed xerrors values. Use errors.As
|
||||
to inspect them:
|
||||
|
||||
var xe *xerrors.Err
|
||||
if errors.As(err, &xe) && xe.Code() == xerrors.ErrNotFound {
|
||||
// object or bucket does not exist
|
||||
}
|
||||
|
||||
When using Native() directly, map errors explicitly with HandleError before returning them:
|
||||
|
||||
obj, err := mc.Native().GetObject(ctx, bucket, key, miniogo.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return HandleError(err) // maps NoSuchKey → ErrNotFound, AccessDenied → ErrPermissionDenied, etc.
|
||||
}
|
||||
defer obj.Close()
|
||||
*/
|
||||
package minio
|
||||
27
errors.go
Normal file
27
errors.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package minio
|
||||
|
||||
import (
|
||||
miniogo "github.com/minio/minio-go/v7"
|
||||
|
||||
"code.nochebuena.dev/go/xerrors"
|
||||
)
|
||||
|
||||
// HandleError maps minio-go errors to xerrors types.
|
||||
// Also available as client.HandleError(err).
|
||||
func HandleError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
resp := miniogo.ToErrorResponse(err)
|
||||
switch resp.Code {
|
||||
case miniogo.NoSuchBucket:
|
||||
return xerrors.New(xerrors.ErrNotFound, "bucket not found").WithError(err)
|
||||
case miniogo.NoSuchKey:
|
||||
return xerrors.New(xerrors.ErrNotFound, "object not found").WithError(err)
|
||||
case miniogo.AccessDenied, miniogo.InvalidAccessKeyID:
|
||||
return xerrors.New(xerrors.ErrPermissionDenied, "access denied").WithError(err)
|
||||
case miniogo.BucketAlreadyExists, miniogo.BucketAlreadyOwnedByYou:
|
||||
return xerrors.New(xerrors.ErrAlreadyExists, "bucket already exists").WithError(err)
|
||||
}
|
||||
return xerrors.New(xerrors.ErrInternal, "unexpected storage error").WithError(err)
|
||||
}
|
||||
55
errors_test.go
Normal file
55
errors_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package minio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
miniogo "github.com/minio/minio-go/v7"
|
||||
|
||||
"code.nochebuena.dev/go/xerrors"
|
||||
)
|
||||
|
||||
func assertXCode(t *testing.T, err error, want xerrors.Code) {
|
||||
t.Helper()
|
||||
var xe *xerrors.Err
|
||||
if !errors.As(err, &xe) {
|
||||
t.Fatalf("expected *xerrors.Err, got %T: %v", err, err)
|
||||
}
|
||||
if xe.Code() != want {
|
||||
t.Errorf("want code %s, got %s", want, xe.Code())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleError_Nil(t *testing.T) {
|
||||
if HandleError(nil) != nil {
|
||||
t.Error("HandleError(nil) should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleError_NoSuchBucket(t *testing.T) {
|
||||
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.NoSuchBucket}), xerrors.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestHandleError_NoSuchKey(t *testing.T) {
|
||||
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.NoSuchKey}), xerrors.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestHandleError_AccessDenied(t *testing.T) {
|
||||
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.AccessDenied}), xerrors.ErrPermissionDenied)
|
||||
}
|
||||
|
||||
func TestHandleError_InvalidAccessKeyID(t *testing.T) {
|
||||
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.InvalidAccessKeyID}), xerrors.ErrPermissionDenied)
|
||||
}
|
||||
|
||||
func TestHandleError_BucketAlreadyExists(t *testing.T) {
|
||||
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.BucketAlreadyExists}), xerrors.ErrAlreadyExists)
|
||||
}
|
||||
|
||||
func TestHandleError_BucketAlreadyOwnedByYou(t *testing.T) {
|
||||
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: miniogo.BucketAlreadyOwnedByYou}), xerrors.ErrAlreadyExists)
|
||||
}
|
||||
|
||||
func TestHandleError_Unknown(t *testing.T) {
|
||||
assertXCode(t, HandleError(miniogo.ErrorResponse{Code: "SomeUnknownCode"}), xerrors.ErrInternal)
|
||||
}
|
||||
35
go.mod
Normal file
35
go.mod
Normal file
@@ -0,0 +1,35 @@
|
||||
module code.nochebuena.dev/go/minio
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
code.nochebuena.dev/go/health v1.0.1
|
||||
code.nochebuena.dev/go/launcher v1.0.1
|
||||
code.nochebuena.dev/go/logz v1.0.1
|
||||
code.nochebuena.dev/go/xerrors v1.0.0
|
||||
github.com/minio/minio-go/v7 v7.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
59
go.sum
Normal file
59
go.sum
Normal file
@@ -0,0 +1,59 @@
|
||||
code.nochebuena.dev/go/health v1.0.1 h1:FwB1sDa9oZBPgIi3kNMUjTVRKQ/Yn77WvlrwNtgFlws=
|
||||
code.nochebuena.dev/go/health v1.0.1/go.mod h1:sy/HVT+E+k2rEuqmW9Q8QX0QnotAQxPHXSexYs7GNMg=
|
||||
code.nochebuena.dev/go/launcher v1.0.1 h1:hbPV8jNtyxfchrT7igzz3M2tKGI3bm8uWkHBXRvSPgg=
|
||||
code.nochebuena.dev/go/launcher v1.0.1/go.mod h1:1KwndVuqm31JN9Dpl9YvOmlogPlKKzoDMo9aRFkYwmM=
|
||||
code.nochebuena.dev/go/logz v1.0.1 h1:kK9aZo19L208CwCr2D/dbSOMaOv62cXsigMSsdFu+8Y=
|
||||
code.nochebuena.dev/go/logz v1.0.1/go.mod h1:YNpNm03fURm2v0ySh/477z9AJhtfRcd9rFOW6fFqgNM=
|
||||
code.nochebuena.dev/go/xerrors v1.0.0 h1:si24SFGa7cHwAxbu75AAEB+a3qRmF118F/BM2SFI7VI=
|
||||
code.nochebuena.dev/go/xerrors v1.0.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
|
||||
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
165
minio.go
Normal file
165
minio.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package minio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
miniogo "github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
|
||||
"code.nochebuena.dev/go/health"
|
||||
"code.nochebuena.dev/go/launcher"
|
||||
"code.nochebuena.dev/go/logz"
|
||||
)
|
||||
|
||||
// Client is the MinIO operation interface for the most common bucket operations.
|
||||
// Inject it into repositories; use Native() on Component for SDK operations not covered here.
|
||||
type Client interface {
|
||||
// PutObject uploads an object to the given bucket and key.
|
||||
PutObject(ctx context.Context, bucket, key string, reader io.Reader, size int64, opts miniogo.PutObjectOptions) (miniogo.UploadInfo, error)
|
||||
// RemoveObject deletes an object from the given bucket and key.
|
||||
RemoveObject(ctx context.Context, bucket, key string, opts miniogo.RemoveObjectOptions) error
|
||||
// GetObject downloads an object and returns it as a readable stream.
|
||||
// The caller must close the returned object after reading.
|
||||
GetObject(ctx context.Context, bucket, key string, opts miniogo.GetObjectOptions) (*miniogo.Object, error)
|
||||
// PresignedGetObject returns a pre-signed URL for downloading an object.
|
||||
PresignedGetObject(ctx context.Context, bucket, key string, expires time.Duration, reqParams url.Values) (*url.URL, error)
|
||||
// HandleError maps a minio-go error to a typed xerrors value.
|
||||
HandleError(err error) error
|
||||
}
|
||||
|
||||
// Component bundles launcher lifecycle, health check, and MinIO client operations.
|
||||
type Component interface {
|
||||
launcher.Component
|
||||
health.Checkable
|
||||
Client
|
||||
// Native returns the underlying minio-go SDK client for operations not covered by Client.
|
||||
Native() *miniogo.Client
|
||||
}
|
||||
|
||||
// Config holds MinIO connection settings. The struct is embeddable in application
|
||||
// config structs and populated from environment variables.
|
||||
type Config struct {
|
||||
Endpoint string `env:"MINIO_ENDPOINT,required"`
|
||||
AccessKey string `env:"MINIO_ACCESS_KEY,required"`
|
||||
SecretKey string `env:"MINIO_SECRET_KEY,required"`
|
||||
UseSSL bool `env:"MINIO_USE_SSL" envDefault:"false"`
|
||||
Bucket string `env:"MINIO_BUCKET,required"`
|
||||
// Region defaults to "us-east-1" — the region MinIO uses by default on self-hosted
|
||||
// deployments. Setting a non-empty region bypasses per-request region detection.
|
||||
Region string `env:"MINIO_REGION" envDefault:"us-east-1"`
|
||||
// Transport is used for testing only. Nil uses the minio-go default transport.
|
||||
Transport http.RoundTripper `env:"-"`
|
||||
}
|
||||
|
||||
var _ Component = (*minioComponent)(nil)
|
||||
|
||||
type minioComponent struct {
|
||||
cfg Config
|
||||
logger logz.Logger
|
||||
mc *miniogo.Client
|
||||
}
|
||||
|
||||
// New returns a MinIO Component. Call lc.Append(mc) to manage its lifecycle.
|
||||
func New(logger logz.Logger, cfg Config) Component {
|
||||
return &minioComponent{cfg: cfg, logger: logger}
|
||||
}
|
||||
|
||||
func (c *minioComponent) OnInit() error {
|
||||
opts := &miniogo.Options{
|
||||
Creds: credentials.NewStaticV4(c.cfg.AccessKey, c.cfg.SecretKey, ""),
|
||||
Secure: c.cfg.UseSSL,
|
||||
Region: c.cfg.Region,
|
||||
Transport: c.cfg.Transport,
|
||||
}
|
||||
mc, err := miniogo.New(c.cfg.Endpoint, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("minio: create client: %w", err)
|
||||
}
|
||||
c.mc = mc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *minioComponent) OnStart() error {
|
||||
if c.mc == nil {
|
||||
return fmt.Errorf("minio: client not initialized")
|
||||
}
|
||||
exists, err := c.mc.BucketExists(context.Background(), c.cfg.Bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("minio: check bucket %q: %w", c.cfg.Bucket, err)
|
||||
}
|
||||
if !exists {
|
||||
if err := c.mc.MakeBucket(context.Background(), c.cfg.Bucket, miniogo.MakeBucketOptions{}); err != nil {
|
||||
return fmt.Errorf("minio: create bucket %q: %w", c.cfg.Bucket, err)
|
||||
}
|
||||
c.logger.Info("minio: bucket created", "bucket", c.cfg.Bucket)
|
||||
}
|
||||
c.logger.Info("minio: connected", "bucket", c.cfg.Bucket)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *minioComponent) OnStop() error {
|
||||
c.mc = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetObject downloads an object as a readable stream. The caller must close the returned object.
|
||||
func (c *minioComponent) GetObject(ctx context.Context, bucket, key string, opts miniogo.GetObjectOptions) (*miniogo.Object, error) {
|
||||
obj, err := c.mc.GetObject(ctx, bucket, key, opts)
|
||||
if err != nil {
|
||||
return nil, HandleError(err)
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// PutObject uploads an object and maps any minio-go error to a typed xerrors value.
|
||||
func (c *minioComponent) PutObject(ctx context.Context, bucket, key string, reader io.Reader, size int64, opts miniogo.PutObjectOptions) (miniogo.UploadInfo, error) {
|
||||
info, err := c.mc.PutObject(ctx, bucket, key, reader, size, opts)
|
||||
if err != nil {
|
||||
return miniogo.UploadInfo{}, HandleError(err)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// RemoveObject deletes an object and maps any minio-go error to a typed xerrors value.
|
||||
func (c *minioComponent) RemoveObject(ctx context.Context, bucket, key string, opts miniogo.RemoveObjectOptions) error {
|
||||
return HandleError(c.mc.RemoveObject(ctx, bucket, key, opts))
|
||||
}
|
||||
|
||||
// PresignedGetObject returns a pre-signed download URL and maps any minio-go error to a typed xerrors value.
|
||||
func (c *minioComponent) PresignedGetObject(ctx context.Context, bucket, key string, expires time.Duration, reqParams url.Values) (*url.URL, error) {
|
||||
u, err := c.mc.PresignedGetObject(ctx, bucket, key, expires, reqParams)
|
||||
if err != nil {
|
||||
return nil, HandleError(err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// HandleError maps a minio-go error to a typed xerrors value.
|
||||
func (c *minioComponent) HandleError(err error) error { return HandleError(err) }
|
||||
|
||||
// Native returns the underlying minio-go SDK client for operations not covered by Client.
|
||||
func (c *minioComponent) Native() *miniogo.Client { return c.mc }
|
||||
|
||||
// HealthCheck verifies MinIO is reachable by checking that the configured bucket exists.
|
||||
func (c *minioComponent) HealthCheck(ctx context.Context) error {
|
||||
if c.mc == nil {
|
||||
return fmt.Errorf("minio: client not initialized")
|
||||
}
|
||||
_, err := c.mc.BucketExists(ctx, c.cfg.Bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("minio: health check: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Name returns the component name used by the health check aggregator.
|
||||
func (c *minioComponent) Name() string { return "minio" }
|
||||
|
||||
// Priority returns LevelCritical — MinIO is the primary store for object data;
|
||||
// an outage means uploads and retrievals fail completely.
|
||||
func (c *minioComponent) Priority() health.Level { return health.LevelCritical }
|
||||
270
minio_test.go
Normal file
270
minio_test.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package minio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
miniogo "github.com/minio/minio-go/v7"
|
||||
|
||||
"code.nochebuena.dev/go/health"
|
||||
"code.nochebuena.dev/go/logz"
|
||||
)
|
||||
|
||||
func newLogger() logz.Logger { return logz.New(logz.Options{}) }
|
||||
|
||||
// mockTransport intercepts all HTTP requests and delegates to fn.
|
||||
type mockTransport struct {
|
||||
fn func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (m *mockTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return m.fn(r)
|
||||
}
|
||||
|
||||
func makeResp(status int) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: make(http.Header),
|
||||
Proto: "HTTP/1.1",
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func testConfig(transport http.RoundTripper) Config {
|
||||
return Config{
|
||||
Endpoint: "localhost:9000",
|
||||
AccessKey: "minioadmin",
|
||||
SecretKey: "minioadmin",
|
||||
Bucket: "test-bucket",
|
||||
Region: "us-east-1",
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
|
||||
// --- construction ---
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
if New(newLogger(), Config{}) == nil {
|
||||
t.Fatal("New returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_Name(t *testing.T) {
|
||||
c := New(newLogger(), Config{}).(health.Checkable)
|
||||
if c.Name() != "minio" {
|
||||
t.Errorf("want minio, got %s", c.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_Priority(t *testing.T) {
|
||||
c := New(newLogger(), Config{}).(health.Checkable)
|
||||
if c.Priority() != health.LevelCritical {
|
||||
t.Error("Priority() != LevelCritical")
|
||||
}
|
||||
}
|
||||
|
||||
// --- nil client guards ---
|
||||
|
||||
func TestComponent_OnStop_NilClient(t *testing.T) {
|
||||
c := &minioComponent{logger: newLogger()}
|
||||
if err := c.OnStop(); err != nil {
|
||||
t.Errorf("OnStop with nil client: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_HealthCheck_NilClient(t *testing.T) {
|
||||
c := &minioComponent{logger: newLogger()}
|
||||
if err := c.HealthCheck(context.Background()); err == nil {
|
||||
t.Error("HealthCheck with nil client should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// --- OnInit + Native ---
|
||||
|
||||
func TestComponent_OnInit_And_Native(t *testing.T) {
|
||||
cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) {
|
||||
return makeResp(http.StatusOK), nil
|
||||
}})
|
||||
c := New(newLogger(), cfg)
|
||||
if err := c.OnInit(); err != nil {
|
||||
t.Fatalf("OnInit: %v", err)
|
||||
}
|
||||
if c.Native() == nil {
|
||||
t.Error("Native() returned nil after OnInit")
|
||||
}
|
||||
}
|
||||
|
||||
// --- OnStart: bucket already exists ---
|
||||
|
||||
func TestComponent_OnStart_BucketExists(t *testing.T) {
|
||||
putCalled := false
|
||||
cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodPut {
|
||||
putCalled = true
|
||||
}
|
||||
return makeResp(http.StatusOK), nil
|
||||
}})
|
||||
c := New(newLogger(), cfg)
|
||||
if err := c.OnInit(); err != nil {
|
||||
t.Fatalf("OnInit: %v", err)
|
||||
}
|
||||
if err := c.OnStart(); err != nil {
|
||||
t.Fatalf("OnStart: %v", err)
|
||||
}
|
||||
if putCalled {
|
||||
t.Error("MakeBucket should not be called when bucket already exists")
|
||||
}
|
||||
}
|
||||
|
||||
// --- OnStart: bucket missing → created ---
|
||||
|
||||
func TestComponent_OnStart_BucketMissing(t *testing.T) {
|
||||
putCalled := false
|
||||
cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodHead {
|
||||
return makeResp(http.StatusNotFound), nil
|
||||
}
|
||||
if r.Method == http.MethodPut {
|
||||
putCalled = true
|
||||
return makeResp(http.StatusOK), nil
|
||||
}
|
||||
return makeResp(http.StatusOK), nil
|
||||
}})
|
||||
c := New(newLogger(), cfg)
|
||||
if err := c.OnInit(); err != nil {
|
||||
t.Fatalf("OnInit: %v", err)
|
||||
}
|
||||
if err := c.OnStart(); err != nil {
|
||||
t.Fatalf("OnStart: %v", err)
|
||||
}
|
||||
if !putCalled {
|
||||
t.Error("MakeBucket should be called when bucket does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
// --- OnStart: bucket check error ---
|
||||
|
||||
func TestComponent_OnStart_BucketError(t *testing.T) {
|
||||
cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) {
|
||||
return makeResp(http.StatusInternalServerError), nil
|
||||
}})
|
||||
c := New(newLogger(), cfg)
|
||||
if err := c.OnInit(); err != nil {
|
||||
t.Fatalf("OnInit: %v", err)
|
||||
}
|
||||
if err := c.OnStart(); err == nil {
|
||||
t.Error("OnStart should return error on 500 response")
|
||||
}
|
||||
}
|
||||
|
||||
// --- HealthCheck: unreachable ---
|
||||
|
||||
func TestComponent_HealthCheck_Unreachable(t *testing.T) {
|
||||
cfg := testConfig(&mockTransport{fn: func(r *http.Request) (*http.Response, error) {
|
||||
return nil, &mockNetError{}
|
||||
}})
|
||||
c := New(newLogger(), cfg)
|
||||
if err := c.OnInit(); err != nil {
|
||||
t.Fatalf("OnInit: %v", err)
|
||||
}
|
||||
if err := c.HealthCheck(context.Background()); err == nil {
|
||||
t.Error("HealthCheck should return error when endpoint is unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client interface methods ---
|
||||
|
||||
func startedComponent(t *testing.T, transport http.RoundTripper) Component {
|
||||
t.Helper()
|
||||
cfg := testConfig(transport)
|
||||
c := New(newLogger(), cfg)
|
||||
if err := c.OnInit(); err != nil {
|
||||
t.Fatalf("OnInit: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestComponent_PutObject_Success(t *testing.T) {
|
||||
c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) {
|
||||
resp := makeResp(http.StatusOK)
|
||||
resp.Header.Set("ETag", `"abc123"`)
|
||||
return resp, nil
|
||||
}})
|
||||
_, err := c.PutObject(context.Background(), "test-bucket", "products/1/img.jpg",
|
||||
strings.NewReader("data"), 4, miniogo.PutObjectOptions{})
|
||||
if err != nil {
|
||||
t.Errorf("PutObject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_RemoveObject_Success(t *testing.T) {
|
||||
c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) {
|
||||
return makeResp(http.StatusNoContent), nil
|
||||
}})
|
||||
if err := c.RemoveObject(context.Background(), "test-bucket", "products/1/img.jpg",
|
||||
miniogo.RemoveObjectOptions{}); err != nil {
|
||||
t.Errorf("RemoveObject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_GetObject_Success(t *testing.T) {
|
||||
c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) {
|
||||
resp := makeResp(http.StatusOK)
|
||||
resp.Body = io.NopCloser(strings.NewReader("image-bytes"))
|
||||
resp.ContentLength = 11
|
||||
return resp, nil
|
||||
}})
|
||||
obj, err := c.GetObject(context.Background(), "test-bucket", "products/1/img.jpg",
|
||||
miniogo.GetObjectOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("GetObject: %v", err)
|
||||
}
|
||||
if obj == nil {
|
||||
t.Error("GetObject returned nil object")
|
||||
}
|
||||
obj.Close()
|
||||
}
|
||||
|
||||
func TestComponent_PresignedGetObject_Success(t *testing.T) {
|
||||
c := startedComponent(t, nil)
|
||||
u, err := c.PresignedGetObject(context.Background(), "test-bucket", "products/1/img.jpg",
|
||||
time.Hour, nil)
|
||||
if err != nil {
|
||||
t.Errorf("PresignedGetObject: %v", err)
|
||||
}
|
||||
if u == nil {
|
||||
t.Error("PresignedGetObject returned nil URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_HandleError_Passthrough(t *testing.T) {
|
||||
c := New(newLogger(), Config{})
|
||||
if c.HandleError(nil) != nil {
|
||||
t.Error("HandleError(nil) should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_OnStop_NilsClient(t *testing.T) {
|
||||
c := startedComponent(t, &mockTransport{fn: func(r *http.Request) (*http.Response, error) {
|
||||
return makeResp(http.StatusOK), nil
|
||||
}})
|
||||
if err := c.OnStop(); err != nil {
|
||||
t.Errorf("OnStop: %v", err)
|
||||
}
|
||||
if c.Native() != nil {
|
||||
t.Error("Native() should return nil after OnStop")
|
||||
}
|
||||
}
|
||||
|
||||
// mockNetError satisfies net.Error for the transport error test.
|
||||
type mockNetError struct{}
|
||||
|
||||
func (e *mockNetError) Error() string { return "mock: connection refused" }
|
||||
func (e *mockNetError) Timeout() bool { return false }
|
||||
func (e *mockNetError) Temporary() bool { return false }
|
||||
Reference in New Issue
Block a user