Moving folders
This commit is contained in:
10
api/.env.example
Normal file
10
api/.env.example
Normal 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
161
api/README.md
Normal 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
30
api/config/config.php
Normal 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
0
api/db/.gitkeep
Normal file
66
api/public/index.php
Normal file
66
api/public/index.php
Normal 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
33
api/src/Autoloader.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
83
api/src/Controllers/ItemController.php
Normal file
83
api/src/Controllers/ItemController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
api/src/Controllers/ListController.php
Normal file
85
api/src/Controllers/ListController.php
Normal 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
168
api/src/Database.php
Normal 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
107
api/src/Env.php
Normal 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);
|
||||
}
|
||||
}
|
||||
89
api/src/Models/ItemModel.php
Normal file
89
api/src/Models/ItemModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
77
api/src/Models/ListModel.php
Normal file
77
api/src/Models/ListModel.php
Normal 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
68
api/src/Router.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user