README.md
# inventory-api-fixture
A Bun runtime project. There is no framework and no separate database server:
storage is `bun:sqlite`, and the HTTP layer is `Bun.serve`.
## Runtime contract
- **Database.** `bun:sqlite` reads and writes the file named by the
`DATABASE_PATH` environment variable. No other datastore is permitted.
- **Server.** `bun run start` starts an HTTP server listening on the port
named by the `PORT` environment variable, implementing every endpoint in
`openapi.yaml`.
- **Migrations.** `bun run migrate` creates the schema described by
`openapi.yaml`'s data model. It must be safe to run more than once against
the same database file: running it twice in a row must succeed both times
with no error and no duplicated schema or data.
- **Readiness.** `GET /health` returns `200` once the service is ready to
accept traffic. It exists only so the process can be health-checked by the
harness that runs this fixture; it is not part of the OpenAPI contract and
must not appear in `openapi.yaml`.
## Contract
`openapi.yaml` is the source of truth for every other endpoint: request and
response shapes, status codes, and pagination. Every validation failure,
on every endpoint, returns the same fixed error shape documented there as
`#/components/schemas/Error`.
openapi.yaml
openapi: "3.1.0"
info:
title: Inventory API
version: "1.0.0"
description: >
Products, warehouses, and stock movements for a small inventory service.
Every list endpoint is paginated with `page` / `pageSize` query parameters.
Every validation failure across every endpoint returns the same fixed error
shape defined in `#/components/schemas/Error`: an object with a single
`error` key holding `code`, `message`, and an optional `details` array of
`{ field, issue }` pairs. `GET /health` is a harness-only readiness probe
and is intentionally not part of this contract.
servers:
- url: http://localhost:4175
paths:
/products:
post:
operationId: createProduct
summary: Create a product
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ProductCreate"
responses:
"201":
description: Product created
content:
application/json:
schema:
$ref: "#/components/schemas/Product"
"400":
description: Validation failure
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: A product with this sku already exists
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
get:
operationId: listProducts
summary: List products
parameters:
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PageSize"
responses:
"200":
description: Paginated list of products
content:
application/json:
schema:
type: object
required: [data, pagination]
properties:
data:
type: array
items:
$ref: "#/components/schemas/Product"
pagination:
$ref: "#/components/schemas/Pagination"
"400":
description: Invalid pagination parameters
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/products/{productId}:
parameters:
- name: productId
in: path
required: true
schema:
type: string
format: uuid
get:
operationId: getProduct
summary: Fetch a product by id
responses:
"200":
description: The product
content:
application/json:
schema:
$ref: "#/components/schemas/Product"
"404":
description: No product with this id
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
patch:
operationId: updateProduct
summary: Update a product's name and/or reorder threshold
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ProductUpdate"
responses:
"200":
description: The updated product
content:
application/json:
schema:
$ref: "#/components/schemas/Product"
"400":
description: Validation failure
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: No product with this id
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/warehouses:
post:
operationId: createWarehouse
summary: Create a warehouse
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/WarehouseCreate"
responses:
"201":
description: Warehouse created
content:
application/json:
schema:
$ref: "#/components/schemas/Warehouse"
"400":
description: Validation failure
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: A warehouse with this code already exists
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
get:
operationId: listWarehouses
summary: List warehouses
parameters:
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PageSize"
responses:
"200":
description: Paginated list of warehouses
content:
application/json:
schema:
type: object
required: [data, pagination]
properties:
data:
type: array
items:
$ref: "#/components/schemas/Warehouse"
pagination:
$ref: "#/components/schemas/Pagination"
"400":
description: Invalid pagination parameters
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/stock-movements:
post:
operationId: createStockMovement
summary: Record a stock movement for a product at a warehouse
description: >
Requires a client-supplied `Idempotency-Key` header. Replaying the same
key with the same request body returns the original movement and does
not double-apply the stock change. Replaying the same key with a
different body is a conflict.
parameters:
- name: Idempotency-Key
in: header
required: true
schema:
type: string
minLength: 1
maxLength: 200
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/StockMovementCreate"
responses:
"201":
description: The movement, applied or replayed idempotently
content:
application/json:
schema:
$ref: "#/components/schemas/StockMovement"
"400":
description: Validation failure
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: Unknown productId or warehouseId
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: >
Either the movement would take stock below zero, or the
Idempotency-Key was reused with a different request body.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/reports/low-stock:
get:
operationId: getLowStockReport
summary: List products whose stock has fallen below their reorder threshold
parameters:
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PageSize"
responses:
"200":
description: Paginated list of low-stock products
content:
application/json:
schema:
type: object
required: [data, pagination]
properties:
data:
type: array
items:
$ref: "#/components/schemas/LowStockItem"
pagination:
$ref: "#/components/schemas/Pagination"
"400":
description: Invalid pagination parameters
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
components:
parameters:
Page:
name: page
in: query
required: false
schema:
type: integer
minimum: 1
default: 1
PageSize:
name: pageSize
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 20
schemas:
Error:
type: object
required: [error]
additionalProperties: false
properties:
error:
type: object
required: [code, message]
additionalProperties: false
properties:
code:
type: string
enum: [validation_error, not_found, conflict, internal_error]
description: >
`validation_error` on every 400 response, `not_found` on every
404 response, `conflict` on every 409 response, and
`internal_error` on any unexpected 500 response.
message:
type: string
details:
type: array
items:
type: object
required: [field, issue]
properties:
field:
type: string
issue:
type: string
Pagination:
type: object
required: [page, pageSize, total, totalPages]
properties:
page:
type: integer
minimum: 1
pageSize:
type: integer
minimum: 1
total:
type: integer
minimum: 0
totalPages:
type: integer
minimum: 0
ProductCreate:
type: object
required: [sku, name, reorderThreshold]
additionalProperties: false
properties:
sku:
type: string
minLength: 1
maxLength: 64
pattern: "^[A-Za-z0-9_-]+$"
name:
type: string
minLength: 1
maxLength: 200
reorderThreshold:
type: integer
minimum: 0
ProductUpdate:
type: object
minProperties: 1
additionalProperties: false
properties:
name:
type: string
minLength: 1
maxLength: 200
reorderThreshold:
type: integer
minimum: 0
Product:
type: object
required: [id, sku, name, reorderThreshold, stock, createdAt, updatedAt]
properties:
id:
type: string
format: uuid
sku:
type: string
name:
type: string
reorderThreshold:
type: integer
minimum: 0
stock:
type: integer
minimum: 0
description: Total quantity across all warehouses, computed from stock movements.
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
WarehouseCreate:
type: object
required: [code, name]
additionalProperties: false
properties:
code:
type: string
minLength: 1
maxLength: 32
pattern: "^[A-Za-z0-9_-]+$"
name:
type: string
minLength: 1
maxLength: 200
Warehouse:
type: object
required: [id, code, name, createdAt]
properties:
id:
type: string
format: uuid
code:
type: string
name:
type: string
createdAt:
type: string
format: date-time
StockMovementCreate:
type: object
required: [productId, warehouseId, type, quantity]
additionalProperties: false
properties:
productId:
type: string
format: uuid
warehouseId:
type: string
format: uuid
type:
type: string
enum: [receipt, shipment, adjustment]
quantity:
type: integer
description: >
For `receipt` and `shipment`, a positive integer amount. For
`adjustment`, a nonzero signed delta applied directly to stock.
note:
type: string
maxLength: 500
StockMovement:
type: object
required:
- id
- productId
- warehouseId
- type
- quantity
- idempotencyKey
- resultingStock
- createdAt
properties:
id:
type: string
format: uuid
productId:
type: string
format: uuid
warehouseId:
type: string
format: uuid
type:
type: string
enum: [receipt, shipment, adjustment]
quantity:
type: integer
note:
type: string
idempotencyKey:
type: string
resultingStock:
type: integer
minimum: 0
description: The product's total stock across all warehouses after this movement was applied.
createdAt:
type: string
format: date-time
LowStockItem:
type: object
required: [productId, sku, name, reorderThreshold, stock]
properties:
productId:
type: string
format: uuid
sku:
type: string
name:
type: string
reorderThreshold:
type: integer
stock:
type: integer