init test commit
This commit is contained in:
1
trade-hub/backend/app/__init__.py
Normal file
1
trade-hub/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Star Citizen Inventory backend package (scunpacked data)."""
|
||||
BIN
trade-hub/backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/api.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/api.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/config.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/loader.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/loader.cpython-314.pyc
Normal file
Binary file not shown.
98
trade-hub/backend/app/api.py
Normal file
98
trade-hub/backend/app/api.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""FastAPI routes for the Star Citizen inventory (scunpacked data)."""
|
||||
import math
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app import loader
|
||||
|
||||
app = FastAPI(
|
||||
title="Star Citizen Inventory API",
|
||||
description="Read-only item inventory built from scunpacked game data.",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# Read-only public GET API; permissive CORS keeps local development easy.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["GET"],
|
||||
)
|
||||
|
||||
store = loader.load_store()
|
||||
|
||||
VALID_CATEGORIES = {"ships", "ship-items", "fps-items"}
|
||||
|
||||
|
||||
def _filter_items(category, search, manufacturer, item_type, size):
|
||||
result = store.items
|
||||
if category is not None:
|
||||
if category not in VALID_CATEGORIES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Invalid category '{category}'. Valid: {sorted(VALID_CATEGORIES)}",
|
||||
)
|
||||
result = [i for i in result if i["category"] == category]
|
||||
if search is not None and search.strip():
|
||||
needle = search.strip().lower()
|
||||
result = [
|
||||
i
|
||||
for i in result
|
||||
if needle in i["name"].lower() or needle in i["class_name"].lower()
|
||||
]
|
||||
if manufacturer is not None and manufacturer.strip():
|
||||
code = manufacturer.strip().lower()
|
||||
result = [i for i in result if i["manufacturer"]["code"].lower() == code]
|
||||
if item_type is not None and item_type.strip():
|
||||
wanted = item_type.strip().lower()
|
||||
result = [i for i in result if (i["type"] or "").lower() == wanted]
|
||||
if size is not None:
|
||||
result = [i for i in result if i["size"] == size]
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"status": "ok", "counts": store.counts}
|
||||
|
||||
|
||||
@app.get("/api/v1/stats")
|
||||
def stats():
|
||||
return {"categories": store.counts, "manufacturers": len(store.manufacturers)}
|
||||
|
||||
|
||||
@app.get("/api/v1/manufacturers")
|
||||
def manufacturers():
|
||||
return store.manufacturers
|
||||
|
||||
|
||||
@app.get("/api/v1/items")
|
||||
def list_items(
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
manufacturer: str | None = None,
|
||||
item_type: str | None = Query(default=None, alias="type"),
|
||||
size: int | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=25, ge=1),
|
||||
):
|
||||
page_size = min(page_size, 100) # cap, don't reject
|
||||
filtered = _filter_items(category, search, manufacturer, item_type, size)
|
||||
total = len(filtered)
|
||||
total_pages = max(1, math.ceil(total / page_size))
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"items": filtered[start : start + page_size],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/items/{class_name}")
|
||||
def item_detail(class_name: str):
|
||||
summary = store.by_class_name.get(class_name)
|
||||
if summary is None:
|
||||
raise HTTPException(status_code=404, detail=f"Item '{class_name}' not found")
|
||||
return {**summary, "details": store.details.get(class_name, {})}
|
||||
12
trade-hub/backend/app/config.py
Normal file
12
trade-hub/backend/app/config.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Configuration for the inventory backend.
|
||||
|
||||
DATA_DIR selects which dataset the API serves:
|
||||
- local dev / tests: small real-schema samples (default) or a fixture dir
|
||||
- container image: the full scunpacked dataset downloaded at build time
|
||||
"""
|
||||
import os
|
||||
|
||||
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_BACKEND_DIR = os.path.dirname(_APP_DIR)
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(_BACKEND_DIR, "data", "samples"))
|
||||
175
trade-hub/backend/app/loader.py
Normal file
175
trade-hub/backend/app/loader.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""Load and normalize scunpacked JSON data into the inventory store.
|
||||
|
||||
Source data: https://github.com/richardthombs/scunpacked
|
||||
(mirrored at https://github.com/StarCitizenWiki/scunpacked-data)
|
||||
|
||||
Real schemas this loader consumes:
|
||||
- ships.json: {"UUID","ClassName","Name","Description","Career","Role",
|
||||
"Manufacturer":{"UUID","Code","Name"},"Size","Length","Width",
|
||||
"Height","Crew","Mass","IsVehicle","IsGravlev","IsSpaceship",...}
|
||||
- ship-items.json / fps-items.json:
|
||||
{"className","reference","itemName","type","subType","size",
|
||||
"grade","name","tags","classification",
|
||||
"stdItem":{"UUID","ClassName","Size","Grade","Width","Height",
|
||||
"Length","Mass","Type","Name","Description","DescriptionText",
|
||||
"Manufacturer":{"Name","Code","UUID"},"Tags":[...],"Ports":[...]}}
|
||||
- manufacturers.json:{"Code","Name","Reference","Description"}
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
from app import config
|
||||
|
||||
PLACEHOLDER = "<= PLACEHOLDER =>"
|
||||
|
||||
CATEGORY_SHIPS = "ships"
|
||||
CATEGORY_SHIP_ITEMS = "ship-items"
|
||||
CATEGORY_FPS_ITEMS = "fps-items"
|
||||
|
||||
|
||||
def _load_json(data_dir, filename):
|
||||
path = os.path.join(data_dir, filename)
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _resolve_name(*candidates, class_name=None):
|
||||
"""Pick the first real display name; fall back to a prettified ClassName.
|
||||
|
||||
Many scunpacked entries have names literally set to "<= PLACEHOLDER =>"
|
||||
(untranslated labels), so the fallback rule is mandatory.
|
||||
"""
|
||||
for candidate in candidates:
|
||||
if candidate and str(candidate).strip() and str(candidate).strip() != PLACEHOLDER:
|
||||
return str(candidate).strip()
|
||||
if class_name:
|
||||
return str(class_name).replace("_", " ")
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def _manufacturer(raw):
|
||||
if not raw:
|
||||
return {"code": "UNKN", "name": "Unknown Manufacturer"}
|
||||
code = raw.get("Code") or raw.get("code") or "UNKN"
|
||||
name = _resolve_name(raw.get("Name"), raw.get("name"), class_name=code)
|
||||
return {"code": code, "name": name}
|
||||
|
||||
|
||||
def _normalize_ship(ship):
|
||||
class_name = ship.get("ClassName") or ""
|
||||
manufacturer = ship.get("Manufacturer") or {}
|
||||
flags = {
|
||||
"is_spaceship": bool(ship.get("IsSpaceship")),
|
||||
"is_vehicle": bool(ship.get("IsVehicle")),
|
||||
"is_gravlev": bool(ship.get("IsGravlev")),
|
||||
}
|
||||
tags = [flag for flag, value in flags.items() if value]
|
||||
return {
|
||||
"uuid": ship.get("UUID") or "",
|
||||
"class_name": class_name,
|
||||
"name": _resolve_name(ship.get("Name"), class_name=class_name),
|
||||
"category": CATEGORY_SHIPS,
|
||||
"type": ship.get("Career") or "Ship",
|
||||
"sub_type": ship.get("Role") or "",
|
||||
"size": ship.get("Size") or 0,
|
||||
"grade": None,
|
||||
"manufacturer": _manufacturer(manufacturer),
|
||||
"classification": "Ship",
|
||||
"description": ship.get("Description") or ship.get("DescriptionText") or "",
|
||||
"tags": tags,
|
||||
}, {
|
||||
"career": ship.get("Career") or "",
|
||||
"role": ship.get("Role") or "",
|
||||
"length": ship.get("Length"),
|
||||
"width": ship.get("Width"),
|
||||
"height": ship.get("Height"),
|
||||
"crew": ship.get("Crew"),
|
||||
"mass": ship.get("Mass"),
|
||||
**flags,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_item(item, category):
|
||||
class_name = item.get("className") or ""
|
||||
std = item.get("stdItem") or {}
|
||||
manufacturer = std.get("Manufacturer") or {}
|
||||
raw_tags = std.get("Tags")
|
||||
tags = list(raw_tags) if isinstance(raw_tags, list) else []
|
||||
summary = {
|
||||
"uuid": std.get("UUID") or item.get("reference") or "",
|
||||
"class_name": class_name,
|
||||
"name": _resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
|
||||
"category": category,
|
||||
"type": item.get("type") or std.get("Type") or "",
|
||||
"sub_type": item.get("subType") or "",
|
||||
"size": item.get("size") if item.get("size") is not None else std.get("Size"),
|
||||
"grade": item.get("grade") if item.get("grade") is not None else std.get("Grade"),
|
||||
"manufacturer": _manufacturer(manufacturer),
|
||||
"classification": item.get("classification") or "",
|
||||
"description": std.get("Description") or std.get("DescriptionText") or "",
|
||||
"tags": tags,
|
||||
}
|
||||
details = {
|
||||
"ports": std.get("Ports") or [],
|
||||
"mass": std.get("Mass"),
|
||||
"width": std.get("Width"),
|
||||
"height": std.get("Height"),
|
||||
"length": std.get("Length"),
|
||||
}
|
||||
return summary, details
|
||||
|
||||
|
||||
class Store:
|
||||
"""In-memory, read-only inventory store built from the scunpacked JSON."""
|
||||
|
||||
def __init__(self, items, details, manufacturers):
|
||||
self.items = items
|
||||
self.details = details
|
||||
self.manufacturers = manufacturers
|
||||
self.by_class_name = {item["class_name"]: item for item in items}
|
||||
self.counts = {
|
||||
CATEGORY_SHIPS: sum(1 for i in items if i["category"] == CATEGORY_SHIPS),
|
||||
CATEGORY_SHIP_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_SHIP_ITEMS),
|
||||
CATEGORY_FPS_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_FPS_ITEMS),
|
||||
}
|
||||
|
||||
|
||||
def load_store(data_dir=None):
|
||||
data_dir = data_dir or config.DATA_DIR
|
||||
|
||||
items = []
|
||||
details = {}
|
||||
|
||||
for ship in _load_json(data_dir, "ships.json"):
|
||||
summary, detail = _normalize_ship(ship)
|
||||
items.append(summary)
|
||||
details[summary["class_name"]] = detail
|
||||
|
||||
for entry in _load_json(data_dir, "ship-items.json"):
|
||||
summary, detail = _normalize_item(entry, CATEGORY_SHIP_ITEMS)
|
||||
items.append(summary)
|
||||
details[summary["class_name"]] = detail
|
||||
|
||||
for entry in _load_json(data_dir, "fps-items.json"):
|
||||
summary, detail = _normalize_item(entry, CATEGORY_FPS_ITEMS)
|
||||
items.append(summary)
|
||||
details[summary["class_name"]] = detail
|
||||
|
||||
# Deterministic order so pagination is stable.
|
||||
items.sort(key=lambda i: (i["category"], i["name"].lower(), i["class_name"]))
|
||||
|
||||
manufacturers = sorted(
|
||||
(
|
||||
{
|
||||
"code": m.get("Code") or "",
|
||||
"name": _resolve_name(m.get("Name"), class_name=m.get("Code") or ""),
|
||||
"description": m.get("Description") or "",
|
||||
}
|
||||
for m in _load_json(data_dir, "manufacturers.json")
|
||||
),
|
||||
key=lambda m: m["name"].lower(),
|
||||
)
|
||||
|
||||
return Store(items, details, manufacturers)
|
||||
Reference in New Issue
Block a user