Moving folders

This commit is contained in:
Chris Smith
2025-12-12 21:34:09 +01:00
parent ae9dc9d805
commit 2700955f31
14 changed files with 209 additions and 141 deletions

10
api/.env.example Normal file
View File

@@ -0,0 +1,10 @@
# Database Configuration
DB_PATH=db/database.sqlite
# CORS Configuration
CORS_ALLOW_ORIGIN=*
CORS_ALLOW_METHODS="GET, POST, PATCH, DELETE, OPTIONS"
CORS_ALLOW_HEADERS=Content-Type
# Application Configuration
ERROR_REPORTING=true

161
api/README.md Normal file
View File

@@ -0,0 +1,161 @@
# list thingy API
A list thingy API using plain PHP for shopping, todos, or other lists. Users create a list instantly without any signup.
They will get a UUID and can share it with friends and collaborate on lists. This API will manage the list with API
calls and will also run a websocket the iOS and Android apps can connect to.
- No accounts
- No ads
- No bloat
- Code that just works for 15 years
## API
#### Lists
- POST /list
- GET /list/{uuid}
- PATCH /list/{uuid}
- DELETE /list/{uuid}
#### Items
- POST /list/{uuid}/item
- PATCH /list/{uuid}/item/{id}
- DELETE /list/{uuid}/item/{id}
## Setup
### Requirements
- PHP 8.0 or higher
- SQLite3 extension (usually included with PHP)
### Installation
1. Clone the repository
2. Copy `.env.example` to `.env`:
```bash
cp .env.example .env
```
3. (Optional) Edit `.env` to customize configuration
4. Start the PHP development server:
```bash
cd public
php -S localhost:8000
```
The database will be automatically created on first request at `db/database.sqlite`.
### Configuration
All configuration is managed through the `.env` file:
- `DB_PATH` - Path to SQLite database file (relative or absolute)
- `CORS_ALLOW_ORIGIN` - CORS allowed origins (default: `*`)
- `CORS_ALLOW_METHODS` - CORS allowed HTTP methods
- `CORS_ALLOW_HEADERS` - CORS allowed headers
- `ERROR_REPORTING` - Enable/disable error reporting (true/false)
## Usage Examples
### Create a List
```bash
curl -X POST http://localhost:8000/list \
-H "Content-Type: application/json" \
-d '{"name":"Shopping List","sharable":true}'
```
Response:
```json
{
"success": true,
"data": {
"id": 1,
"uuid": "a1b2c3d4e5f6...",
"name": "Shopping List",
"sharable": true,
"created_at": "2025-12-12 12:00:00"
}
}
```
### Get a List with Items
```bash
curl http://localhost:8000/list/{uuid}
```
### Update a List
```bash
curl -X PATCH http://localhost:8000/list/{uuid} \
-H "Content-Type: application/json" \
-d '{"name":"Updated List Name"}'
```
### Delete a List
```bash
curl -X DELETE http://localhost:8000/list/{uuid}
```
### Add Item to List
```bash
curl -X POST http://localhost:8000/list/{uuid}/item \
-H "Content-Type: application/json" \
-d '{"name":"Milk","quantity":2.5,"category":"Dairy"}'
```
### Update Item
```bash
curl -X PATCH http://localhost:8000/list/{uuid}/item/{id} \
-H "Content-Type: application/json" \
-d '{"quantity":3}'
```
### Delete Item (Soft Delete)
```bash
curl -X DELETE http://localhost:8000/list/{uuid}/item/{id}
```
## Models
#### List Model
- id
- uuid
- name
- sharable (boolean)
- created_at
#### Item Model
- id
- list_id
- category
- quantity (double)
- name
- created_at
- deleted_at
## Architecture
Pure PHP with no dependencies:
- **Database**: SQLite with PDO
- **Router**: Custom lightweight router
- **Structure**: Simple MVC pattern
- **CORS**: Enabled for mobile/web apps
## WebSocket Server
WebSocket support will be implemented separately for real-time collaboration features.

30
api/config/config.php Normal file
View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../src/Env.php';
use App\Env;
$envPath = __DIR__ . '/../.env';
if (file_exists($envPath)) {
Env::load($envPath);
}
$dbPath = Env::get('DB_PATH', 'db/database.sqlite');
if (!str_starts_with($dbPath, '/')) {
$dbPath = __DIR__ . '/../' . $dbPath;
}
return [
'db' => [
'path' => $dbPath,
],
'cors' => [
'allow_origin' => Env::get('CORS_ALLOW_ORIGIN', '*'),
'allow_methods' => Env::get('CORS_ALLOW_METHODS', 'GET, POST, PATCH, DELETE, OPTIONS'),
'allow_headers' => Env::get('CORS_ALLOW_HEADERS', 'Content-Type'),
],
'error_reporting' => Env::getBool('ERROR_REPORTING', true),
];

0
api/db/.gitkeep Normal file
View File

66
api/public/index.php Normal file
View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../src/Autoloader.php';
$autoloader = new Autoloader('App', __DIR__ . '/../src');
$autoloader->register();
$config = require __DIR__ . '/../config/config.php';
if ($config['error_reporting']) {
error_reporting(E_ALL);
ini_set('display_errors', '1');
}
header('Access-Control-Allow-Origin: ' . $config['cors']['allow_origin']);
header('Access-Control-Allow-Methods: ' . $config['cors']['allow_methods']);
header('Access-Control-Allow-Headers: ' . $config['cors']['allow_headers']);
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
use App\Database;
use App\Router;
use App\Controllers\ListController;
use App\Controllers\ItemController;
Database::init($config['db']['path']);
$router = new Router();
$router->addRoute('POST', '/list', function () {
ListController::create();
});
$router->addRoute('GET', '/list/{uuid}', function (string $uuid) {
ListController::show($uuid);
});
$router->addRoute('PATCH', '/list/{uuid}', function (string $uuid) {
ListController::update($uuid);
});
$router->addRoute('DELETE', '/list/{uuid}', function (string $uuid) {
ListController::delete($uuid);
});
$router->addRoute('POST', '/list/{uuid}/item', function (string $uuid) {
ItemController::create($uuid);
});
$router->addRoute('PATCH', '/list/{uuid}/item/{id}', function (string $uuid, string $id) {
ItemController::update($uuid, $id);
});
$router->addRoute('DELETE', '/list/{uuid}/item/{id}', function (string $uuid, string $id) {
ItemController::delete($uuid, $id);
});
try {
$router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
} catch (Exception $e) {
Router::sendResponse(['error' => 'Internal server error'], 500);
}

33
api/src/Autoloader.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
class Autoloader
{
private string $baseDir;
private string $namespace;
public function __construct(string $namespace, string $baseDir)
{
$this->namespace = rtrim($namespace, '\\') . '\\';
$this->baseDir = rtrim($baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
}
public function register(): void
{
spl_autoload_register([$this, 'loadClass']);
}
private function loadClass(string $class): void
{
if (strpos($class, $this->namespace) !== 0) {
return;
}
$relativeClass = substr($class, strlen($this->namespace));
$file = $this->baseDir . str_replace('\\', DIRECTORY_SEPARATOR, $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
}
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\ListModel;
use App\Models\ItemModel;
use App\Router;
use Exception;
class ItemController
{
public static function create(string $listUuid): void
{
$input = Router::getJsonInput();
if (empty($input['name'])) {
Router::sendResponse(['error' => 'Name is required'], 400);
}
try {
$list = ListModel::findByUuid($listUuid);
if (!$list) {
Router::sendResponse(['error' => 'List not found'], 404);
}
$item = ItemModel::create($list['id'], $input);
Router::sendResponse($item, 201);
} catch (Exception $e) {
Router::sendResponse(['error' => 'Failed to create item'], 500);
}
}
public static function update(string $listUuid, string $itemId): void
{
$input = Router::getJsonInput();
if (empty($input)) {
Router::sendResponse(['error' => 'No data provided'], 400);
}
try {
$list = ListModel::findByUuid($listUuid);
if (!$list) {
Router::sendResponse(['error' => 'List not found'], 404);
}
$item = ItemModel::update($list['id'], (int) $itemId, $input);
if (!$item) {
Router::sendResponse(['error' => 'Item not found'], 404);
}
Router::sendResponse($item, 200);
} catch (Exception $e) {
Router::sendResponse(['error' => 'Failed to update item'], 500);
}
}
public static function delete(string $listUuid, string $itemId): void
{
try {
$list = ListModel::findByUuid($listUuid);
if (!$list) {
Router::sendResponse(['error' => 'List not found'], 404);
}
$deleted = ItemModel::delete($list['id'], (int) $itemId);
if (!$deleted) {
Router::sendResponse(['error' => 'Item not found'], 404);
}
http_response_code(204);
exit;
} catch (Exception $e) {
Router::sendResponse(['error' => 'Failed to delete item'], 500);
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Models\ListModel;
use App\Models\ItemModel;
use App\Router;
use Exception;
class ListController
{
public static function create(): void
{
$input = Router::getJsonInput();
if (empty($input['name'])) {
Router::sendResponse(['error' => 'Name is required'], 400);
}
$sharable = $input['sharable'] ?? true;
try {
$list = ListModel::create($input['name'], $sharable);
Router::sendResponse($list, 201);
} catch (Exception $e) {
Router::sendResponse(['error' => 'Failed to create list'], 500);
}
}
public static function show(string $uuid): void
{
try {
$list = ListModel::findByUuid($uuid);
if (!$list) {
Router::sendResponse(['error' => 'List not found'], 404);
}
$items = ItemModel::findByListId($list['id']);
$list['items'] = $items;
Router::sendResponse($list, 200);
} catch (Exception $e) {
Router::sendResponse(['error' => 'Failed to retrieve list'], 500);
}
}
public static function update(string $uuid): void
{
$input = Router::getJsonInput();
if (empty($input)) {
Router::sendResponse(['error' => 'No data provided'], 400);
}
try {
$list = ListModel::update($uuid, $input);
if (!$list) {
Router::sendResponse(['error' => 'List not found'], 404);
}
Router::sendResponse($list, 200);
} catch (Exception $e) {
Router::sendResponse(['error' => 'Failed to update list'], 500);
}
}
public static function delete(string $uuid): void
{
try {
$deleted = ListModel::delete($uuid);
if (!$deleted) {
Router::sendResponse(['error' => 'List not found'], 404);
}
http_response_code(204);
exit;
} catch (Exception $e) {
Router::sendResponse(['error' => 'Failed to delete list'], 500);
}
}
}

168
api/src/Database.php Normal file
View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
class Database
{
private static ?PDO $connection = null;
private static string $dbPath;
public static function init(string $dbPath): void
{
self::$dbPath = $dbPath;
}
public static function getConnection(): PDO
{
if (self::$connection === null) {
$dbExists = file_exists(self::$dbPath);
self::$connection = new PDO('sqlite:' . self::$dbPath);
self::$connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
self::$connection->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
if (!$dbExists) {
self::createSchema();
}
}
return self::$connection;
}
private static function createSchema(): void
{
$sql = <<<SQL
CREATE TABLE IF NOT EXISTS lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
sharable INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL,
category TEXT,
quantity REAL DEFAULT 1.0,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME,
FOREIGN KEY (list_id) REFERENCES lists(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_items_list_id ON items(list_id);
CREATE INDEX IF NOT EXISTS idx_items_deleted_at ON items(deleted_at);
SQL;
self::$connection->exec($sql);
}
public static function select(string $table, array $where = []): array
{
$db = self::getConnection();
$sql = "SELECT * FROM {$table}";
if (!empty($where)) {
$conditions = [];
foreach (array_keys($where) as $key) {
$conditions[] = "{$key} = :{$key}";
}
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}
$stmt = $db->prepare($sql);
$stmt->execute($where);
return $stmt->fetchAll();
}
public static function selectOne(string $table, array $where): ?array
{
$results = self::select($table, $where);
return $results[0] ?? null;
}
public static function insert(string $table, array $data): int
{
$db = self::getConnection();
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO {$table} ({$columns}) VALUES ({$placeholders})";
$stmt = $db->prepare($sql);
$stmt->execute($data);
return (int) $db->lastInsertId();
}
public static function update(string $table, array $data, array $where): int
{
$db = self::getConnection();
$setParts = [];
foreach (array_keys($data) as $key) {
$setParts[] = "{$key} = :set_{$key}";
}
$whereParts = [];
foreach (array_keys($where) as $key) {
$whereParts[] = "{$key} = :where_{$key}";
}
$sql = "UPDATE {$table} SET " . implode(', ', $setParts) . ' WHERE ' . implode(' AND ', $whereParts);
$params = [];
foreach ($data as $key => $value) {
$params["set_{$key}"] = $value;
}
foreach ($where as $key => $value) {
$params["where_{$key}"] = $value;
}
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
public static function delete(string $table, array $where): int
{
$db = self::getConnection();
$conditions = [];
foreach (array_keys($where) as $key) {
$conditions[] = "{$key} = :{$key}";
}
$sql = "DELETE FROM {$table} WHERE " . implode(' AND ', $conditions);
$stmt = $db->prepare($sql);
$stmt->execute($where);
return $stmt->rowCount();
}
public static function query(string $sql, array $params = []): array
{
$db = self::getConnection();
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
public static function execute(string $sql, array $params = []): int
{
$db = self::getConnection();
$stmt = $db->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
}

107
api/src/Env.php Normal file
View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App;
class Env
{
private static array $variables = [];
private static bool $loaded = false;
public static function load(string $path): void
{
if (self::$loaded) {
return;
}
if (!file_exists($path)) {
throw new \RuntimeException("Environment file not found: {$path}");
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if (empty($line) || str_starts_with($line, '#')) {
continue;
}
if (!str_contains($line, '=')) {
continue;
}
[$name, $value] = explode('=', $line, 2);
$name = trim($name);
$value = trim($value);
$value = self::parseValue($value);
self::$variables[$name] = $value;
$_ENV[$name] = $value;
if (!array_key_exists($name, $_SERVER)) {
$_SERVER[$name] = $value;
}
}
self::$loaded = true;
}
private static function parseValue(string $value): string
{
if (preg_match('/^"(.*)"$/', $value, $matches)) {
return $matches[1];
}
if (preg_match('/^\'(.*)\'$/', $value, $matches)) {
return $matches[1];
}
return $value;
}
public static function get(string $name, ?string $default = null): ?string
{
if (isset(self::$variables[$name])) {
return self::$variables[$name];
}
if (isset($_ENV[$name])) {
return $_ENV[$name];
}
if (isset($_SERVER[$name])) {
return $_SERVER[$name];
}
return $default;
}
public static function require(string $name): string
{
$value = self::get($name);
if ($value === null) {
throw new \RuntimeException("Required environment variable '{$name}' is not set");
}
return $value;
}
public static function has(string $name): bool
{
return self::get($name) !== null;
}
public static function getBool(string $name, bool $default = false): bool
{
$value = self::get($name);
if ($value === null) {
return $default;
}
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Database;
class ItemModel
{
public static function create(int $listId, array $data): array
{
$insertData = [
'list_id' => $listId,
'name' => $data['name'] ?? '',
'category' => $data['category'] ?? null,
'quantity' => $data['quantity'] ?? 1.0,
];
$id = Database::insert('items', $insertData);
return self::findById($id);
}
public static function findById(int $id): ?array
{
$item = Database::selectOne('items', ['id' => $id]);
if ($item) {
$item['quantity'] = (float) $item['quantity'];
}
return $item;
}
public static function findByListId(int $listId): array
{
$items = Database::query(
'SELECT * FROM items WHERE list_id = :list_id AND deleted_at IS NULL ORDER BY created_at DESC',
['list_id' => $listId]
);
foreach ($items as &$item) {
$item['quantity'] = (float) $item['quantity'];
}
return $items;
}
public static function update(int $listId, int $itemId, array $data): ?array
{
$item = Database::selectOne('items', ['id' => $itemId, 'list_id' => $listId]);
if (!$item) {
return null;
}
$updateData = [];
if (isset($data['name'])) {
$updateData['name'] = $data['name'];
}
if (isset($data['category'])) {
$updateData['category'] = $data['category'];
}
if (isset($data['quantity'])) {
$updateData['quantity'] = $data['quantity'];
}
if (empty($updateData)) {
return self::findById($itemId);
}
Database::update('items', $updateData, ['id' => $itemId, 'list_id' => $listId]);
return self::findById($itemId);
}
public static function delete(int $listId, int $itemId): bool
{
$updated = Database::execute(
'UPDATE items SET deleted_at = CURRENT_TIMESTAMP WHERE id = :id AND list_id = :list_id AND deleted_at IS NULL',
['id' => $itemId, 'list_id' => $listId]
);
return $updated > 0;
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Database;
class ListModel
{
public static function create(string $name, bool $sharable = false): array
{
$uuid = bin2hex(random_bytes(16));
$id = Database::insert('lists', [
'uuid' => $uuid,
'name' => $name,
'sharable' => $sharable ? 1 : 0,
]);
return self::findById($id);
}
public static function findById(int $id): ?array
{
$list = Database::selectOne('lists', ['id' => $id]);
if ($list) {
$list['sharable'] = (bool) $list['sharable'];
}
return $list;
}
public static function findByUuid(string $uuid): ?array
{
$list = Database::selectOne('lists', ['uuid' => $uuid]);
if ($list) {
$list['sharable'] = (bool) $list['sharable'];
}
return $list;
}
public static function update(string $uuid, array $data): ?array
{
$list = self::findByUuid($uuid);
if (!$list) {
return null;
}
$updateData = [];
if (isset($data['name'])) {
$updateData['name'] = $data['name'];
}
if (isset($data['sharable'])) {
$updateData['sharable'] = $data['sharable'] ? 1 : 0;
}
if (empty($updateData)) {
return $list;
}
Database::update('lists', $updateData, ['uuid' => $uuid]);
return self::findByUuid($uuid);
}
public static function delete(string $uuid): bool
{
$deleted = Database::delete('lists', ['uuid' => $uuid]);
return $deleted > 0;
}
}

68
api/src/Router.php Normal file
View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App;
class Router
{
private array $routes = [];
public function addRoute(string $method, string $path, callable $handler): void
{
$this->routes[] = [
'method' => strtoupper($method),
'path' => $path,
'handler' => $handler,
];
}
public function dispatch(string $method, string $uri): void
{
$method = strtoupper($method);
$uri = parse_url($uri, PHP_URL_PATH);
$uri = rtrim($uri, '/') ?: '/';
foreach ($this->routes as $route) {
if ($route['method'] !== $method) {
continue;
}
$pattern = $this->convertPathToRegex($route['path']);
if (preg_match($pattern, $uri, $matches)) {
array_shift($matches);
call_user_func_array($route['handler'], $matches);
return;
}
}
$this->sendResponse(['error' => 'Not Found'], 404);
}
private function convertPathToRegex(string $path): string
{
$path = rtrim($path, '/') ?: '/';
$pattern = preg_replace('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', '([^/]+)', $path);
return '#^' . $pattern . '$#';
}
public static function getJsonInput(): array
{
$input = file_get_contents('php://input');
return json_decode($input, true) ?? [];
}
public static function sendResponse(array $data, int $statusCode = 200): void
{
http_response_code($statusCode);
header('Content-Type: application/json');
if ($statusCode >= 400) {
echo json_encode(['success' => false, 'error' => $data['error'] ?? 'An error occurred']);
} else {
echo json_encode(['success' => true, 'data' => $data]);
}
exit;
}
}