refactor: rename all minio references to garage, drop minio SDK for boto3

The backend no longer mentions MinIO anywhere: MinioClient -> GarageClient
(minio_client.py -> garage_client.py), MINIO_* env vars -> GARAGE_*, and
the `minio` Python package is replaced with `boto3` (AWS's generic S3
client) since Garage is the only storage backend this project targets
now. S3 API semantics are unchanged, so document_service.py's behavior
is identical — this is a naming and dependency swap, not a logic change.

Also updates test fixtures/names (FakeMinioClient -> FakeGarageClient,
FakeMinioSDK -> FakeS3SDK) and storage/README.md, which no longer frames
Garage as a MinIO replacement (that migration note has served its purpose
now that no MinIO reference remains in the codebase).

Backend: 54/54 tests passing. Frontend: 38/38 tests passing (unaffected,
verified for regressions since it talks to the backend only through the
unchanged REST API).
This commit is contained in:
Tianyang 2026-08-01 10:51:49 +08:00
parent 0f0b7e9d32
commit 360e9e5a14
16 changed files with 264 additions and 272 deletions

View File

@ -3,10 +3,10 @@ import os
class Settings: class Settings:
def __init__(self): def __init__(self):
self.minio_endpoint = os.environ.get("MINIO_ENDPOINT", "localhost:9000") self.garage_endpoint = os.environ.get("GARAGE_ENDPOINT", "localhost:3900")
self.minio_access_key = os.environ.get("MINIO_ACCESS_KEY", "minioadmin") self.garage_access_key = os.environ.get("GARAGE_ACCESS_KEY", "")
self.minio_secret_key = os.environ.get("MINIO_SECRET_KEY", "minioadmin") self.garage_secret_key = os.environ.get("GARAGE_SECRET_KEY", "")
self.minio_bucket = os.environ.get("MINIO_BUCKET", "dochub") self.garage_bucket = os.environ.get("GARAGE_BUCKET", "dochub")
self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700") self.meilisearch_host = os.environ.get("MEILISEARCH_HOST", "http://localhost:7700")
self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "") self.meilisearch_api_key = os.environ.get("MEILISEARCH_API_KEY", "")
self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents") self.meilisearch_index = os.environ.get("MEILISEARCH_INDEX", "documents")

View File

@ -1,21 +1,21 @@
from functools import lru_cache from functools import lru_cache
import boto3
import meilisearch import meilisearch
from minio import Minio
from app.config import settings from app.config import settings
from app.document_service import DocumentService from app.document_service import DocumentService
from app.minio_client import MinioClient from app.garage_client import GarageClient
from app.search_client import SearchClient from app.search_client import SearchClient
@lru_cache @lru_cache
def _get_minio_sdk() -> Minio: def _get_garage_sdk():
return Minio( return boto3.client(
settings.minio_endpoint, "s3",
access_key=settings.minio_access_key, endpoint_url=f"http://{settings.garage_endpoint}",
secret_key=settings.minio_secret_key, aws_access_key_id=settings.garage_access_key,
secure=False, aws_secret_access_key=settings.garage_secret_key,
) )
@ -26,9 +26,9 @@ def _get_meilisearch_sdk() -> meilisearch.Client:
@lru_cache @lru_cache
def get_document_service() -> DocumentService: def get_document_service() -> DocumentService:
minio_client = MinioClient(_get_minio_sdk(), settings.minio_bucket) garage_client = GarageClient(_get_garage_sdk(), settings.garage_bucket)
search_client = SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index) search_client = SearchClient(_get_meilisearch_sdk(), settings.meilisearch_index)
return DocumentService(minio_client, search_client) return DocumentService(garage_client, search_client)
@lru_cache @lru_cache

View File

@ -53,13 +53,13 @@ def _guess_content_type(key: str) -> str:
class DocumentService: class DocumentService:
def __init__(self, minio_client, search_client): def __init__(self, garage_client, search_client):
self._minio_client = minio_client self._garage_client = garage_client
self._search_client = search_client self._search_client = search_client
def list_documents(self, path: str) -> dict: def list_documents(self, path: str) -> dict:
prefix = path if not path or path.endswith("/") else f"{path}/" prefix = path if not path or path.endswith("/") else f"{path}/"
entries = self._minio_client.list_objects(prefix) entries = self._garage_client.list_objects(prefix)
folders = [] folders = []
files = [] files = []
for entry in entries: for entry in entries:
@ -81,7 +81,7 @@ class DocumentService:
def get_document(self, key: str) -> dict: def get_document(self, key: str) -> dict:
_validate_path(key) _validate_path(key)
try: try:
data = self._minio_client.get_object(key) data = self._garage_client.get_object(key)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise DocumentNotFoundError(key) from exc raise DocumentNotFoundError(key) from exc
try: try:
@ -107,7 +107,7 @@ class DocumentService:
def save_document(self, key: str, content: bytes, content_type: str) -> dict: def save_document(self, key: str, content: bytes, content_type: str) -> dict:
_validate_path(key) _validate_path(key)
self._minio_client.put_object(key, content, content_type) self._garage_client.put_object(key, content, content_type)
try: try:
text = self._extract_text(content, content_type) text = self._extract_text(content, content_type)
title = key.rsplit("/", 1)[-1] title = key.rsplit("/", 1)[-1]
@ -119,7 +119,7 @@ class DocumentService:
def delete_document(self, key: str) -> None: def delete_document(self, key: str) -> None:
_validate_path(key) _validate_path(key)
self._minio_client.delete_object(key) self._garage_client.delete_object(key)
try: try:
self._search_client.delete_document(key) self._search_client.delete_document(key)
except Exception: except Exception:

View File

@ -0,0 +1,50 @@
import io
from botocore.exceptions import ClientError
class GarageClient:
def __init__(self, client, bucket: str):
self._client = client
self._bucket = bucket
def list_objects(self, prefix: str) -> list[dict]:
paginator = self._client.get_paginator("list_objects_v2")
result = []
for page in paginator.paginate(Bucket=self._bucket, Prefix=prefix, Delimiter="/"):
for common_prefix in page.get("CommonPrefixes", []):
result.append({
"key": common_prefix["Prefix"],
"size": None,
"is_dir": True,
"modifiedAt": None,
})
for obj in page.get("Contents", []):
result.append({
"key": obj["Key"],
"size": obj["Size"],
"is_dir": obj["Key"].endswith("/"),
"modifiedAt": obj["LastModified"].isoformat() if obj.get("LastModified") else None,
})
return result
def get_object(self, key: str) -> bytes:
try:
response = self._client.get_object(Bucket=self._bucket, Key=key)
except ClientError as exc:
if exc.response.get("Error", {}).get("Code") in ("NoSuchKey", "404"):
raise FileNotFoundError(key) from exc
raise
return response["Body"].read()
def put_object(self, key: str, data: bytes, content_type: str) -> None:
self._client.put_object(
Bucket=self._bucket,
Key=key,
Body=io.BytesIO(data),
ContentLength=len(data),
ContentType=content_type,
)
def delete_object(self, key: str) -> None:
self._client.delete_object(Bucket=self._bucket, Key=key)

View File

@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.config import settings from app.config import settings
from app.dependencies import _get_minio_sdk from app.dependencies import _get_garage_sdk
from app.routers import documents, search from app.routers import documents, search
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -26,11 +26,12 @@ app.include_router(search.router)
@app.on_event("startup") @app.on_event("startup")
def ensure_bucket_exists(): def ensure_bucket_exists():
try: try:
minio_sdk = _get_minio_sdk() garage_sdk = _get_garage_sdk()
if not minio_sdk.bucket_exists(settings.minio_bucket): existing_buckets = {b["Name"] for b in garage_sdk.list_buckets().get("Buckets", [])}
minio_sdk.make_bucket(settings.minio_bucket) if settings.garage_bucket not in existing_buckets:
garage_sdk.create_bucket(Bucket=settings.garage_bucket)
except Exception: except Exception:
logger.warning("Could not verify/create MinIO bucket '%s' on startup", settings.minio_bucket, exc_info=True) logger.warning("Could not verify/create Garage bucket '%s' on startup", settings.garage_bucket, exc_info=True)
@app.get("/health") @app.get("/health")

View File

@ -1,45 +0,0 @@
import io
from minio.error import S3Error
class MinioClient:
def __init__(self, client, bucket: str):
self._client = client
self._bucket = bucket
def list_objects(self, prefix: str) -> list[dict]:
objects = self._client.list_objects(self._bucket, prefix=prefix, recursive=False)
result = []
for obj in objects:
result.append({
"key": obj.object_name,
"size": obj.size,
"is_dir": obj.object_name.endswith("/"),
"modifiedAt": obj.last_modified.isoformat() if obj.last_modified else None,
})
return result
def get_object(self, key: str) -> bytes:
try:
response = self._client.get_object(self._bucket, key)
except S3Error as exc:
if exc.code == "NoSuchKey":
raise FileNotFoundError(key) from exc
raise
try:
return response.read()
finally:
response.close()
response.release_conn()
def put_object(self, key: str, data: bytes, content_type: str) -> None:
self._client.put_object(
self._bucket,
key,
io.BytesIO(data),
length=len(data),
content_type=content_type,
)
def delete_object(self, key: str) -> None:
self._client.remove_object(self._bucket, key)

View File

@ -1,6 +1,6 @@
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
minio==7.2.9 boto3==1.35.36
meilisearch==0.31.5 meilisearch==0.31.5
pytest==8.3.3 pytest==8.3.3
httpx==0.27.2 httpx==0.27.2

View File

@ -10,7 +10,7 @@ from app.document_service import (
) )
class FakeMinioClient: class FakeGarageClient:
def __init__(self): def __init__(self):
self.entries = [] self.entries = []
self.stored = {} self.stored = {}
@ -55,13 +55,13 @@ class FakeSearchClient:
def test_list_documents_at_root_returns_folders_and_files(): def test_list_documents_at_root_returns_folders_and_files():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
minio_client.entries = [ garage_client.entries = [
{"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None}, {"key": "产品文档/", "is_dir": True, "size": 0, "modifiedAt": None},
{"key": "团队规范/", "is_dir": True, "size": 0, "modifiedAt": None}, {"key": "团队规范/", "is_dir": True, "size": 0, "modifiedAt": None},
{"key": "README.md", "is_dir": False, "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"}, {"key": "README.md", "is_dir": False, "size": 128, "modifiedAt": "2026-08-01T09:00:00Z"},
] ]
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(garage_client, FakeSearchClient())
result = service.list_documents("") result = service.list_documents("")
@ -72,9 +72,9 @@ def test_list_documents_at_root_returns_folders_and_files():
def test_get_document_returns_content_and_content_type(): def test_get_document_returns_content_and_content_type():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
minio_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 MinIO 存储文档。".encode("utf-8") garage_client.stored["产品文档/架构设计.md"] = "# 架构设计\n\nDocHub 使用 Garage 存储文档。".encode("utf-8")
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(garage_client, FakeSearchClient())
result = service.get_document("产品文档/架构设计.md") result = service.get_document("产品文档/架构设计.md")
@ -84,17 +84,17 @@ def test_get_document_returns_content_and_content_type():
def test_get_document_raises_not_found_for_missing_key(): def test_get_document_raises_not_found_for_missing_key():
service = DocumentService(FakeMinioClient(), FakeSearchClient()) service = DocumentService(FakeGarageClient(), FakeSearchClient())
with pytest.raises(DocumentNotFoundError): with pytest.raises(DocumentNotFoundError):
service.get_document("不存在/文档.md") service.get_document("不存在/文档.md")
def test_get_document_returns_base64_content_for_binary_data_without_raising(): def test_get_document_returns_base64_content_for_binary_data_without_raising():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
binary_data = b"\x89PNG\r\n\x1a\n\x00\x01\xff\xfe" binary_data = b"\x89PNG\r\n\x1a\n\x00\x01\xff\xfe"
minio_client.stored["产品文档/图片.png"] = binary_data garage_client.stored["产品文档/图片.png"] = binary_data
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(garage_client, FakeSearchClient())
result = service.get_document("产品文档/图片.png") result = service.get_document("产品文档/图片.png")
@ -104,139 +104,139 @@ def test_get_document_returns_base64_content_for_binary_data_without_raising():
def test_get_document_returns_utf8_encoding_for_text_content(): def test_get_document_returns_utf8_encoding_for_text_content():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
minio_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8") garage_client.stored["产品文档/架构设计.md"] = "# 架构设计".encode("utf-8")
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(garage_client, FakeSearchClient())
result = service.get_document("产品文档/架构设计.md") result = service.get_document("产品文档/架构设计.md")
assert result["encoding"] == "utf-8" assert result["encoding"] == "utf-8"
def test_save_document_writes_to_minio_then_indexes_content(): def test_save_document_writes_to_garage_then_indexes_content():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
search_client = FakeSearchClient() search_client = FakeSearchClient()
service = DocumentService(minio_client, search_client) service = DocumentService(garage_client, search_client)
content = "# 架构设计\n\nDocHub 使用 **MinIO** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8") content = "# 架构设计\n\nDocHub 使用 **Garage** 存储文档,使用 Meilisearch 做全文检索。".encode("utf-8")
result = service.save_document("产品文档/架构设计.md", content, "text/markdown") result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
assert minio_client.stored["产品文档/架构设计.md"] == content assert garage_client.stored["产品文档/架构设计.md"] == content
assert search_client.indexed[0]["key"] == "产品文档/架构设计.md" assert search_client.indexed[0]["key"] == "产品文档/架构设计.md"
assert search_client.indexed[0]["path"] == "产品文档" assert search_client.indexed[0]["path"] == "产品文档"
assert search_client.indexed[0]["title"] == "架构设计.md" assert search_client.indexed[0]["title"] == "架构设计.md"
assert "MinIO" in search_client.indexed[0]["content"] assert "Garage" in search_client.indexed[0]["content"]
assert "#" not in search_client.indexed[0]["content"] assert "#" not in search_client.indexed[0]["content"]
assert "key" in result and "updatedAt" in result assert "key" in result and "updatedAt" in result
def test_save_document_does_not_fail_when_index_update_fails(): def test_save_document_does_not_fail_when_index_update_fails():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
search_client = FakeSearchClient(fail_on_index=True) search_client = FakeSearchClient(fail_on_index=True)
service = DocumentService(minio_client, search_client) service = DocumentService(garage_client, search_client)
content = b"# doc" content = b"# doc"
result = service.save_document("产品文档/架构设计.md", content, "text/markdown") result = service.save_document("产品文档/架构设计.md", content, "text/markdown")
assert minio_client.stored["产品文档/架构设计.md"] == content assert garage_client.stored["产品文档/架构设计.md"] == content
assert result["key"] == "产品文档/架构设计.md" assert result["key"] == "产品文档/架构设计.md"
def test_delete_document_removes_from_minio_and_search_index(): def test_delete_document_removes_from_garage_and_search_index():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
minio_client.stored["产品文档/旧文档.md"] = b"old content" garage_client.stored["产品文档/旧文档.md"] = b"old content"
search_client = FakeSearchClient() search_client = FakeSearchClient()
service = DocumentService(minio_client, search_client) service = DocumentService(garage_client, search_client)
service.delete_document("产品文档/旧文档.md") service.delete_document("产品文档/旧文档.md")
assert "产品文档/旧文档.md" not in minio_client.stored assert "产品文档/旧文档.md" not in garage_client.stored
assert search_client.deleted == ["产品文档/旧文档.md"] assert search_client.deleted == ["产品文档/旧文档.md"]
def test_delete_document_does_not_fail_when_index_delete_fails(): def test_delete_document_does_not_fail_when_index_delete_fails():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
minio_client.stored["产品文档/旧文档.md"] = b"old content" garage_client.stored["产品文档/旧文档.md"] = b"old content"
search_client = FakeSearchClient(fail_on_delete=True) search_client = FakeSearchClient(fail_on_delete=True)
service = DocumentService(minio_client, search_client) service = DocumentService(garage_client, search_client)
service.delete_document("产品文档/旧文档.md") service.delete_document("产品文档/旧文档.md")
assert "产品文档/旧文档.md" not in minio_client.stored assert "产品文档/旧文档.md" not in garage_client.stored
def test_upload_document_writes_file_and_returns_metadata(): def test_upload_document_writes_file_and_returns_metadata():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(garage_client, FakeSearchClient())
data = b"# uploaded doc" data = b"# uploaded doc"
result = service.upload_document("产品文档/新上传.md", data, "text/markdown") result = service.upload_document("产品文档/新上传.md", data, "text/markdown")
assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)} assert result == {"key": "产品文档/新上传.md", "name": "新上传.md", "size": len(data)}
assert minio_client.stored["产品文档/新上传.md"] == data assert garage_client.stored["产品文档/新上传.md"] == data
def test_upload_document_rejects_unsupported_file_extension(): def test_upload_document_rejects_unsupported_file_extension():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(garage_client, FakeSearchClient())
with pytest.raises(UnsupportedFileTypeError): with pytest.raises(UnsupportedFileTypeError):
service.upload_document("产品文档/病毒.exe", b"data", "application/octet-stream") service.upload_document("产品文档/病毒.exe", b"data", "application/octet-stream")
assert "产品文档/病毒.exe" not in minio_client.stored assert "产品文档/病毒.exe" not in garage_client.stored
def test_upload_document_rejects_oversized_file(): def test_upload_document_rejects_oversized_file():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(garage_client, FakeSearchClient())
data = b"x" * (MAX_UPLOAD_SIZE_BYTES + 1) data = b"x" * (MAX_UPLOAD_SIZE_BYTES + 1)
with pytest.raises(FileTooLargeError): with pytest.raises(FileTooLargeError):
service.upload_document("产品文档/大文件.md", data, "text/markdown") service.upload_document("产品文档/大文件.md", data, "text/markdown")
assert "产品文档/大文件.md" not in minio_client.stored assert "产品文档/大文件.md" not in garage_client.stored
def test_get_document_rejects_path_traversal_key(): def test_get_document_rejects_path_traversal_key():
service = DocumentService(FakeMinioClient(), FakeSearchClient()) service = DocumentService(FakeGarageClient(), FakeSearchClient())
with pytest.raises(InvalidPathError): with pytest.raises(InvalidPathError):
service.get_document("../../etc/passwd") service.get_document("../../etc/passwd")
def test_save_document_rejects_path_traversal_key(): def test_save_document_rejects_path_traversal_key():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(garage_client, FakeSearchClient())
with pytest.raises(InvalidPathError): with pytest.raises(InvalidPathError):
service.save_document("../../etc/passwd", b"data", "text/plain") service.save_document("../../etc/passwd", b"data", "text/plain")
assert "../../etc/passwd" not in minio_client.stored assert "../../etc/passwd" not in garage_client.stored
def test_delete_document_rejects_path_traversal_key(): def test_delete_document_rejects_path_traversal_key():
service = DocumentService(FakeMinioClient(), FakeSearchClient()) service = DocumentService(FakeGarageClient(), FakeSearchClient())
with pytest.raises(InvalidPathError): with pytest.raises(InvalidPathError):
service.delete_document("../../etc/passwd") service.delete_document("../../etc/passwd")
def test_upload_document_rejects_path_traversal_key(): def test_upload_document_rejects_path_traversal_key():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(garage_client, FakeSearchClient())
with pytest.raises(InvalidPathError): with pytest.raises(InvalidPathError):
service.upload_document("../../etc/passwd", b"data", "text/plain") service.upload_document("../../etc/passwd", b"data", "text/plain")
assert "../../etc/passwd" not in minio_client.stored assert "../../etc/passwd" not in garage_client.stored
def test_upload_document_accepts_allowed_image_extension(): def test_upload_document_accepts_allowed_image_extension():
minio_client = FakeMinioClient() garage_client = FakeGarageClient()
service = DocumentService(minio_client, FakeSearchClient()) service = DocumentService(garage_client, FakeSearchClient())
data = b"\x89PNG\r\n\x1a\n" data = b"\x89PNG\r\n\x1a\n"
result = service.upload_document("产品文档/图片.png", data, "image/png") result = service.upload_document("产品文档/图片.png", data, "image/png")
assert result == {"key": "产品文档/图片.png", "name": "图片.png", "size": len(data)} assert result == {"key": "产品文档/图片.png", "name": "图片.png", "size": len(data)}
assert minio_client.stored["产品文档/图片.png"] == data assert garage_client.stored["产品文档/图片.png"] == data

View File

@ -0,0 +1,100 @@
from datetime import datetime, timezone
import pytest
from botocore.exceptions import ClientError
from app.garage_client import GarageClient
class _FakePaginator:
def __init__(self, sdk):
self._sdk = sdk
def paginate(self, Bucket, Prefix="", Delimiter="/"):
contents = []
for key, (data, content_type, last_modified) in self._sdk.objects.items():
if key.startswith(Prefix):
contents.append({"Key": key, "Size": len(data), "LastModified": last_modified})
yield {"Contents": contents, "CommonPrefixes": []}
class FakeS3SDK:
def __init__(self):
self.objects = {}
self.put_calls = []
self.removed_keys = []
def get_paginator(self, name):
return _FakePaginator(self)
def get_object(self, Bucket, Key):
if Key not in self.objects:
raise ClientError({"Error": {"Code": "NoSuchKey"}}, "GetObject")
data, _, _ = self.objects[Key]
class _Body:
def read(self_inner):
return data
return {"Body": _Body()}
def put_object(self, Bucket, Key, Body, ContentLength, ContentType):
data = Body.read()
self.objects[Key] = (data, ContentType, datetime.now(timezone.utc))
self.put_calls.append((Bucket, Key, ContentType))
def delete_object(self, Bucket, Key):
del self.objects[Key]
self.removed_keys.append(Key)
def test_list_objects_returns_keys_matching_prefix():
sdk = FakeS3SDK()
sdk.objects["产品文档/架构设计.md"] = (b"# hi", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
sdk.objects["团队规范/编码规范.md"] = (b"# rules", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
client = GarageClient(sdk, bucket="dochub")
result = client.list_objects("产品文档/")
assert len(result) == 1
assert result[0]["key"] == "产品文档/架构设计.md"
assert result[0]["size"] == 4
def test_get_object_returns_bytes():
sdk = FakeS3SDK()
sdk.objects["产品文档/架构设计.md"] = (b"# architecture", "text/markdown", datetime.now(timezone.utc))
client = GarageClient(sdk, bucket="dochub")
data = client.get_object("产品文档/架构设计.md")
assert data == b"# architecture"
def test_get_object_raises_file_not_found_for_missing_key():
sdk = FakeS3SDK()
client = GarageClient(sdk, bucket="dochub")
with pytest.raises(FileNotFoundError):
client.get_object("不存在/文档.md")
def test_put_object_stores_data_with_content_type():
sdk = FakeS3SDK()
client = GarageClient(sdk, bucket="dochub")
client.put_object("产品文档/新文档.md", b"# new doc", "text/markdown")
assert sdk.objects["产品文档/新文档.md"][0] == b"# new doc"
assert sdk.put_calls == [("dochub", "产品文档/新文档.md", "text/markdown")]
def test_delete_object_removes_key():
sdk = FakeS3SDK()
sdk.objects["产品文档/旧文档.md"] = (b"old", "text/markdown", datetime.now(timezone.utc))
client = GarageClient(sdk, bucket="dochub")
client.delete_object("产品文档/旧文档.md")
assert "产品文档/旧文档.md" not in sdk.objects
assert sdk.removed_keys == ["产品文档/旧文档.md"]

View File

@ -18,33 +18,33 @@ def test_health_returns_ok():
def test_startup_creates_bucket_when_missing(monkeypatch): def test_startup_creates_bucket_when_missing(monkeypatch):
fake_minio = MagicMock() fake_garage = MagicMock()
fake_minio.bucket_exists.return_value = False fake_garage.list_buckets.return_value = {"Buckets": []}
monkeypatch.setattr(main_module, "_get_minio_sdk", lambda: fake_minio) monkeypatch.setattr(main_module, "_get_garage_sdk", lambda: fake_garage)
with TestClient(app): with TestClient(app):
pass pass
fake_minio.bucket_exists.assert_called() fake_garage.list_buckets.assert_called()
fake_minio.make_bucket.assert_called() fake_garage.create_bucket.assert_called()
def test_startup_does_not_create_bucket_when_it_already_exists(monkeypatch): def test_startup_does_not_create_bucket_when_it_already_exists(monkeypatch):
fake_minio = MagicMock() fake_garage = MagicMock()
fake_minio.bucket_exists.return_value = True fake_garage.list_buckets.return_value = {"Buckets": [{"Name": "dochub"}]}
monkeypatch.setattr(main_module, "_get_minio_sdk", lambda: fake_minio) monkeypatch.setattr(main_module, "_get_garage_sdk", lambda: fake_garage)
with TestClient(app): with TestClient(app):
pass pass
fake_minio.make_bucket.assert_not_called() fake_garage.create_bucket.assert_not_called()
def test_startup_does_not_crash_app_when_minio_is_unreachable(monkeypatch): def test_startup_does_not_crash_app_when_garage_is_unreachable(monkeypatch):
def raise_connection_error(): def raise_connection_error():
raise ConnectionError("minio unreachable") raise ConnectionError("garage unreachable")
monkeypatch.setattr(main_module, "_get_minio_sdk", raise_connection_error) monkeypatch.setattr(main_module, "_get_garage_sdk", raise_connection_error)
with TestClient(app) as test_client: with TestClient(app) as test_client:
response = test_client.get("/health") response = test_client.get("/health")

View File

@ -1,108 +0,0 @@
from datetime import datetime, timezone
import pytest
from minio.error import S3Error
from app.minio_client import MinioClient
class _FakeObject:
def __init__(self, object_name, size, last_modified):
self.object_name = object_name
self.size = size
self.last_modified = last_modified
class _FakeResponse:
def __init__(self, data: bytes):
self._data = data
self.closed = False
self.released = False
def read(self):
return self._data
def close(self):
self.closed = True
def release_conn(self):
self.released = True
class FakeMinioSDK:
def __init__(self):
self.objects = {}
self.put_calls = []
self.removed_keys = []
def list_objects(self, bucket, prefix="", recursive=False):
for key, (data, content_type, last_modified) in self.objects.items():
if key.startswith(prefix):
yield _FakeObject(key, len(data), last_modified)
def get_object(self, bucket, key):
if key not in self.objects:
raise S3Error("NoSuchKey", "Object does not exist", key, "req-1", "host-1", None)
data, _, _ = self.objects[key]
return _FakeResponse(data)
def put_object(self, bucket, key, stream, length, content_type):
data = stream.read()
self.objects[key] = (data, content_type, datetime.now(timezone.utc))
self.put_calls.append((bucket, key, content_type))
def remove_object(self, bucket, key):
del self.objects[key]
self.removed_keys.append(key)
def test_list_objects_returns_keys_matching_prefix():
sdk = FakeMinioSDK()
sdk.objects["产品文档/架构设计.md"] = (b"# hi", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
sdk.objects["团队规范/编码规范.md"] = (b"# rules", "text/markdown", datetime(2026, 8, 1, tzinfo=timezone.utc))
client = MinioClient(sdk, bucket="dochub")
result = client.list_objects("产品文档/")
assert len(result) == 1
assert result[0]["key"] == "产品文档/架构设计.md"
assert result[0]["size"] == 4
def test_get_object_returns_bytes():
sdk = FakeMinioSDK()
sdk.objects["产品文档/架构设计.md"] = (b"# architecture", "text/markdown", datetime.now(timezone.utc))
client = MinioClient(sdk, bucket="dochub")
data = client.get_object("产品文档/架构设计.md")
assert data == b"# architecture"
def test_get_object_raises_file_not_found_for_missing_key():
sdk = FakeMinioSDK()
client = MinioClient(sdk, bucket="dochub")
with pytest.raises(FileNotFoundError):
client.get_object("不存在/文档.md")
def test_put_object_stores_data_with_content_type():
sdk = FakeMinioSDK()
client = MinioClient(sdk, bucket="dochub")
client.put_object("产品文档/新文档.md", b"# new doc", "text/markdown")
assert sdk.objects["产品文档/新文档.md"][0] == b"# new doc"
assert sdk.put_calls == [("dochub", "产品文档/新文档.md", "text/markdown")]
def test_delete_object_removes_key():
sdk = FakeMinioSDK()
sdk.objects["产品文档/旧文档.md"] = (b"old", "text/markdown", datetime.now(timezone.utc))
client = MinioClient(sdk, bucket="dochub")
client.delete_object("产品文档/旧文档.md")
assert "产品文档/旧文档.md" not in sdk.objects
assert sdk.removed_keys == ["产品文档/旧文档.md"]

View File

@ -48,5 +48,5 @@ def test_search_returns_empty_results_for_no_match(client):
def test_search_passes_query_string_to_search_client(client): def test_search_passes_query_string_to_search_client(client):
client.get("/api/search", params={"q": "MinIO"}) client.get("/api/search", params={"q": "Garage"})
assert client.fake_client.queries == ["MinIO"] assert client.fake_client.queries == ["Garage"]

View File

@ -40,7 +40,7 @@ def test_index_document_adds_document_with_encoded_id():
client = SearchClient(sdk, index_name="documents") client = SearchClient(sdk, index_name="documents")
client.index_document( client.index_document(
"产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 MinIO 存储文档" "产品文档/架构设计.md", "产品文档", "架构设计.md", "DocHub 使用 Garage 存储文档"
) )
index = sdk.indexes["documents"] index = sdk.indexes["documents"]
@ -49,7 +49,7 @@ def test_index_document_adds_document_with_encoded_id():
assert stored["key"] == "产品文档/架构设计.md" assert stored["key"] == "产品文档/架构设计.md"
assert stored["path"] == "产品文档" assert stored["path"] == "产品文档"
assert stored["title"] == "架构设计.md" assert stored["title"] == "架构设计.md"
assert stored["content"] == "DocHub 使用 MinIO 存储文档" assert stored["content"] == "DocHub 使用 Garage 存储文档"
def test_delete_document_removes_document(): def test_delete_document_removes_document():

View File

@ -6,7 +6,7 @@ def test_html_to_text_strips_tags():
<html> <html>
<body> <body>
<h1>架构设计</h1> <h1>架构设计</h1>
<p>DocHub 是一个基于 <strong>MinIO</strong> 的文档管理系统</p> <p>DocHub 是一个基于 <strong>Garage</strong> 的文档管理系统</p>
<ul> <ul>
<li>浏览文档</li> <li>浏览文档</li>
<li>全文检索</li> <li>全文检索</li>
@ -16,7 +16,7 @@ def test_html_to_text_strips_tags():
""" """
result = html_to_text(html) result = html_to_text(html)
assert "架构设计" in result assert "架构设计" in result
assert "DocHub 是一个基于 MinIO 的文档管理系统。" in result assert "DocHub 是一个基于 Garage 的文档管理系统。" in result
assert "浏览文档" in result assert "浏览文档" in result
assert "<h1>" not in result assert "<h1>" not in result
assert "<strong>" not in result assert "<strong>" not in result
@ -33,7 +33,7 @@ def test_markdown_to_text_strips_headers_and_emphasis():
## 背景 ## 背景
DocHub 是一个 **基于 MinIO** _文档管理系统_支持全文检索 DocHub 是一个 **基于 Garage** _文档管理系统_支持全文检索
""" """
result = markdown_to_text(markdown) result = markdown_to_text(markdown)
assert "#" not in result assert "#" not in result
@ -41,21 +41,21 @@ DocHub 是一个 **基于 MinIO** 的 _文档管理系统_支持全文检索
assert "_" not in result assert "_" not in result
assert "架构设计" in result assert "架构设计" in result
assert "背景" in result assert "背景" in result
assert "基于 MinIO" in result assert "基于 Garage" in result
assert "文档管理系统" in result assert "文档管理系统" in result
def test_markdown_to_text_strips_links_and_list_markers(): def test_markdown_to_text_strips_links_and_list_markers():
markdown = """## 功能列表 markdown = """## 功能列表
- 浏览 [MinIO](https://min.io) 中存储的文档 - 浏览 [Garage](https://garagehq.deuxfleurs.fr) 中存储的文档
- 支持 [Meilisearch](https://www.meilisearch.com) 全文检索 - 支持 [Meilisearch](https://www.meilisearch.com) 全文检索
1. 在线编辑并保存 1. 在线编辑并保存
""" """
result = markdown_to_text(markdown) result = markdown_to_text(markdown)
assert "[" not in result assert "[" not in result
assert "](" not in result assert "](" not in result
assert "浏览 MinIO 中存储的文档" in result assert "浏览 Garage 中存储的文档" in result
assert "支持 Meilisearch 全文检索" in result assert "支持 Meilisearch 全文检索" in result
assert "在线编辑并保存" in result assert "在线编辑并保存" in result

View File

@ -2,10 +2,10 @@ services:
backend: backend:
build: ./backend build: ./backend
environment: environment:
MINIO_ENDPOINT: ${MINIO_ENDPOINT:-minio:9000} GARAGE_ENDPOINT: ${GARAGE_ENDPOINT:-garage:3900}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} GARAGE_ACCESS_KEY: ${GARAGE_ACCESS_KEY:-}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} GARAGE_SECRET_KEY: ${GARAGE_SECRET_KEY:-}
MINIO_BUCKET: ${MINIO_BUCKET:-dochub} GARAGE_BUCKET: ${GARAGE_BUCKET:-dochub}
MEILISEARCH_HOST: http://meilisearch:7700 MEILISEARCH_HOST: http://meilisearch:7700
MEILISEARCH_API_KEY: ${MEILISEARCH_API_KEY:-} MEILISEARCH_API_KEY: ${MEILISEARCH_API_KEY:-}
MEILISEARCH_INDEX: ${MEILISEARCH_INDEX:-documents} MEILISEARCH_INDEX: ${MEILISEARCH_INDEX:-documents}

View File

@ -1,13 +1,10 @@
# DocHub Storage (Garage) # DocHub Storage (Garage)
S3-compatible object storage for DocHub, deployed independently from the S3-compatible object storage for DocHub, deployed independently from the
app stack in `docker-compose.yml` at the repo root — same separation as app stack in `docker-compose.yml` at the repo root, using
was used for MinIO, just backed by [Garage](https://garagehq.deuxfleurs.fr/) [Garage](https://garagehq.deuxfleurs.fr/) — a fully open source (AGPL)
instead. Garage was chosen because MinIO's community edition has been object store purpose-built for self-hosting. `backend/app/garage_client.py`
scaling back features and its licensing direction has grown less talks to it over the standard S3 API via `boto3`.
predictable; Garage is fully open source (AGPL) and purpose-built for
self-hosting. Both speak the S3 API, so `backend/app/minio_client.py`
needed no code changes — only connection settings differ.
Single-node setup, matching this project's learning/small-team scope Single-node setup, matching this project's learning/small-team scope
(`replication_factor = 1` in `garage.toml` — no data redundancy across (`replication_factor = 1` in `garage.toml` — no data redundancy across
@ -23,20 +20,17 @@ docker compose exec garage /garage key info dochub-backend
``` ```
The last command prints an access key ID and secret key. Set those as The last command prints an access key ID and secret key. Set those as
`MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` for the DocHub backend (the env `GARAGE_ACCESS_KEY` / `GARAGE_SECRET_KEY` for the DocHub backend.
var names stay `MINIO_*` because `backend/app/config.py` was written
against the S3 API, not against MinIO specifically).
## Connecting the backend ## Connecting the backend
From the repo root, when running `docker compose up` there: From the repo root, when running `docker compose up` there:
```bash ```bash
MINIO_ENDPOINT=<this-server-ip>:3900 \ GARAGE_ENDPOINT=<this-server-ip>:3900 \
MINIO_ACCESS_KEY=<from init-garage.sh> \ GARAGE_ACCESS_KEY=<from init-garage.sh> \
MINIO_SECRET_KEY=<from init-garage.sh> \ GARAGE_SECRET_KEY=<from init-garage.sh> \
docker compose up -d docker compose up -d
``` ```
Garage's S3 API listens on port `3900` (vs MinIO's `9000`) — that's the Garage's S3 API listens on port `3900`.
only endpoint difference the rest of the stack needs to know about.