diff --git a/.gitignore b/.gitignore index 0bd1f211..7ca5953b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ venv/ .env.test.local .env.production.local .vscode +projects.db npm-debug.log* yarn-debug.log* diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..283838e6 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,143 @@ +# Storage API + +A Flask-based REST API for providing storage capabilities (key-value pairs and file storage) using SQLite as the database. + +## Features + +- **Key-Value Storage**: Store and retrieve key-value pairs +- **File Storage**: Store, retrieve, list, rename, and delete files and directories +- **SQLite Database**: Persistent storage using SQLite +- **CORS Support**: Cross-origin resource sharing for frontend integration + +## API Endpoints + +### Key-Value Storage (Entries) + +- `GET /entries/` - Fetch entry value by key (supports `?default=value` query param) +- `POST /entries/` - Save entry value + +### File and Directory Operations + +- `GET /storage/` - List directory contents (if path ends with `/`) or fetch file content (if path doesn't end with `/`) +- `POST /storage/` - Save file content (path must not end with `/`) +- `DELETE /storage/` - Delete file or directory +- `POST /storage/rename` - Rename file or directory + +## Setup + 1. cd /backend + 2. python3.12 -m venv ./venv + 3. source ./venv/bin/activate + 4. python3.12 -m pip install -r requirements.txt + 5. deactivate + +## Running the Application + 1. source ./venv/bin/activate + 2. python main.py + 3. The API will be available at `http://localhost:5001` + +## Example Usage + +### Key-Value Operations (Entries) + +#### Save an Entry +```bash +curl -X POST http://localhost:5001/entries/user_settings \ + -H "Content-Type: application/json" \ + -d '{"value": "{\"theme\": \"dark\", \"language\": \"en\"}"}' +``` + +#### Fetch an Entry +```bash +curl http://localhost:5001/entries/user_settings +``` + +#### Fetch an Entry with Default Value +```bash +curl "http://localhost:5001/entries/missing_key?default=default_value" +``` + +### File and Directory Operations + +#### Save a File +```bash +curl -X POST http://localhost:5001/storage/projects/robot1/main.py \ + -H "Content-Type: application/json" \ + -d '{"content": "# Robot main file\nprint(\"Hello Robot!\")"}' +``` + +#### Fetch a File +```bash +curl http://localhost:5001/storage/projects/robot1/main.py +``` + +#### List Files in Directory +```bash +curl http://localhost:5001/storage/projects/ +``` + +#### Rename a File +```bash +curl -X POST http://localhost:5001/storage/rename \ + -H "Content-Type: application/json" \ + -d '{"old_path": "projects/robot1/main.py", "new_path": "projects/robot1/robot_main.py"}' +``` + +#### Rename a Directory +```bash +curl -X POST http://localhost:5001/storage/rename \ + -H "Content-Type: application/json" \ + -d '{"old_path": "projects/robot1/", "new_path": "projects/my_robot/"}' +``` + +#### Delete a File +```bash +curl -X DELETE http://localhost:5001/storage/projects/robot1/old_file.py +``` + +#### Delete a Directory +```bash +curl -X DELETE http://localhost:5001/storage/projects/old_project/ +``` + +## Data Models + +### StorageEntry (Key-Value Storage) +- `id`: Integer (Primary Key) +- `entry_key`: String (Unique, Required) +- `entry_value`: Text (Required) + +### StorageFile (File Storage) +- `id`: Integer (Primary Key) +- `file_path`: String (Unique, Required) +- `file_content`: Text (Required) + +## Database + +The application creates a SQLite database file `storage.db` in the backend directory automatically when first run. + +## CORS Support + +The API includes CORS (Cross-Origin Resource Sharing) headers to allow frontend applications running on different ports to access the API. This is essential for development when the frontend runs on a different port than the backend. + +## Frontend Integration + +The server-side storage implementation in `src/storage/server_side_storage.ts` provides a TypeScript interface that connects to this Flask backend. It implements the `Storage` interface with methods for: + +- Key-value storage (`saveEntry`, `fetchEntry`) +- File operations (`saveFile`, `fetchFileContentText`, `list`, `rename`, `delete`) + +Example usage in TypeScript: +```typescript +import { ServerSideStorage } from './storage/server_side_storage'; + +const storage = new ServerSideStorage(); + +// Save and fetch key-value data +await storage.saveEntry('user_settings', JSON.stringify({theme: 'dark'})); +const settings = await storage.fetchEntry('user_settings', '{}'); + +// File operations +await storage.saveFile('projects/robot1/main.py', 'print("Hello Robot!")'); +const content = await storage.fetchFileContentText('projects/robot1/main.py'); +const files = await storage.list('projects/'); +``` diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 00000000..0c8a6f58 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,248 @@ +from flask import Flask, request, jsonify +from flask_restful import Resource, Api +from flask_sqlalchemy import SQLAlchemy +import json +import os + +app = Flask(__name__) +api = Api(app) + +# Add CORS headers +@app.after_request +def after_request(response): + response.headers.add('Access-Control-Allow-Origin', '*') + response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') + response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') + return response + +@app.before_request +def handle_preflight(): + if request.method == "OPTIONS": + response = jsonify({'message': 'OK'}) + response.headers.add('Access-Control-Allow-Origin', '*') + response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization') + response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS') + return response + +# Configure SQLite database +basedir = os.path.abspath(os.path.dirname(__file__)) +app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(basedir, "projects.db")}' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + +db = SQLAlchemy(app) + +# Storage models for key-value pairs and files +class StorageEntry(db.Model): + id = db.Column(db.Integer, primary_key=True) + entry_key = db.Column(db.String(255), nullable=False, unique=True) + entry_value = db.Column(db.Text, nullable=False) + + def to_dict(self): + return { + 'key': self.entry_key, + 'value': self.entry_value + } + +class StorageFile(db.Model): + id = db.Column(db.Integer, primary_key=True) + file_path = db.Column(db.String(500), nullable=False, unique=True) + file_content = db.Column(db.Text, nullable=False) + + def to_dict(self): + return { + 'path': self.file_path, + 'content': self.file_content + } + +# Storage Resources for key-value and file operations +class StorageEntryResource(Resource): + def get(self, entry_key): + """Fetch entry value by key""" + entry = StorageEntry.query.filter_by(entry_key=entry_key).first() + if entry: + return {'value': entry.entry_value} + else: + # Return default value if provided in query params + default_value = request.args.get('default', '') + return {'value': default_value} + + def post(self, entry_key): + """Save entry value""" + data = request.get_json() + if not data or 'value' not in data: + return {'error': 'Entry value is required'}, 400 + + entry = StorageEntry.query.filter_by(entry_key=entry_key).first() + if entry: + entry.entry_value = data['value'] + else: + entry = StorageEntry(entry_key=entry_key, entry_value=data['value']) + db.session.add(entry) + + try: + db.session.commit() + return {'message': 'Entry saved successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to save entry'}, 500 + +class StorageResource(Resource): + def get(self, path): + """Get file content or list directory based on path""" + # Handle empty path as root directory + if not path: + path = "" + + if path.endswith('/') or path == "": + # List directory + # For root directory, find all files + if path == "": + files = StorageFile.query.all() + # Extract top-level files and directories + children = set() + for file in files: + if '/' in file.file_path: + # It's in a subdirectory, add the directory name + dir_name = file.file_path.split('/')[0] + '/' + children.add(dir_name) + else: + # It's a top-level file + children.add(file.file_path) + else: + # Find all files that start with this path + files = StorageFile.query.filter(StorageFile.file_path.like(f'{path}%')).all() + + # Extract immediate children (not nested) + children = set() + for file in files: + relative_path = file.file_path[len(path):] + if '/' in relative_path: + # It's in a subdirectory, add the directory name + dir_name = relative_path.split('/')[0] + '/' + children.add(dir_name) + else: + # It's a direct child file + children.add(relative_path) + + return {'files': sorted(list(children))} + else: + # Get file content + file_obj = StorageFile.query.filter_by(file_path=path).first() + if file_obj: + return {'content': file_obj.file_content} + else: + return {'error': 'File not found'}, 404 + + def post(self, path): + """Save file content (only for files, not directories)""" + if path.endswith('/'): + return {'error': 'Cannot save content to a directory path'}, 400 + + data = request.get_json() + if not data or 'content' not in data: + return {'error': 'File content is required'}, 400 + + file_obj = StorageFile.query.filter_by(file_path=path).first() + if file_obj: + file_obj.file_content = data['content'] + else: + file_obj = StorageFile(file_path=path, file_content=data['content']) + db.session.add(file_obj) + + try: + db.session.commit() + return {'message': 'File saved successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to save file'}, 500 + + def delete(self, path): + """Delete file or directory""" + if path.endswith('/'): + # Delete directory (all files starting with this path) + files = StorageFile.query.filter(StorageFile.file_path.like(f'{path}%')).all() + for file_obj in files: + db.session.delete(file_obj) + else: + # Delete specific file + file_obj = StorageFile.query.filter_by(file_path=path).first() + if not file_obj: + return {'error': 'File not found'}, 404 + db.session.delete(file_obj) + + try: + db.session.commit() + return {'message': 'Deleted successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to delete'}, 500 + +class StorageFileRenameResource(Resource): + def post(self): + """Rename file or directory""" + data = request.get_json() + if not data or 'old_path' not in data or 'new_path' not in data: + return {'error': 'Both old_path and new_path are required'}, 400 + + old_path = data['old_path'] + new_path = data['new_path'] + + if old_path.endswith('/'): + # Rename directory (all files starting with old_path) + files = StorageFile.query.filter(StorageFile.file_path.like(f'{old_path}%')).all() + for file_obj in files: + new_file_path = file_obj.file_path.replace(old_path, new_path, 1) + file_obj.file_path = new_file_path + else: + # Rename specific file + file_obj = StorageFile.query.filter_by(file_path=old_path).first() + if not file_obj: + return {'error': 'File not found'}, 404 + file_obj.file_path = new_path + + try: + db.session.commit() + return {'message': 'Renamed successfully'} + except Exception as e: + db.session.rollback() + return {'error': 'Failed to rename'}, 500 + +class StorageRootResource(Resource): + def get(self): + """List all top-level files and directories""" + files = StorageFile.query.all() + # Extract top-level files and directories + children = set() + for file in files: + if '/' in file.file_path: + # It's in a subdirectory, add the directory name + dir_name = file.file_path.split('/')[0] + '/' + children.add(dir_name) + else: + # It's a top-level file + children.add(file.file_path) + + return {'files': sorted(list(children))} + +# Register API routes +# Storage API routes (more specific routes first) +api.add_resource(StorageEntryResource, '/entries/') +api.add_resource(StorageFileRenameResource, '/storage/rename') +api.add_resource(StorageRootResource, '/storage/') +api.add_resource(StorageResource, '/storage/') + +@app.route('/') +def index(): + return jsonify({ + 'message': 'Storage API', + 'endpoints': { + 'entries': '/entries/', + 'storage': '/storage/', + 'storage_rename': '/storage/rename' + } + }) + +if __name__ == '__main__': + with app.app_context(): + db.create_all() + app.run(debug=True, port=5001) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..071ff7ed --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,19 @@ +aniso8601==10.0.1 +blinker==1.9.0 +certifi==2025.8.3 +charset-normalizer==3.4.3 +click==8.2.1 +Flask==3.1.1 +Flask-RESTful==0.3.10 +Flask-SQLAlchemy==3.1.1 +idna==3.10 +itsdangerous==2.2.0 +Jinja2==3.1.6 +MarkupSafe==3.0.2 +pytz==2025.2 +requests==2.32.5 +six==1.17.0 +SQLAlchemy==2.0.42 +typing_extensions==4.14.1 +urllib3==2.5.0 +Werkzeug==3.1.3 diff --git a/backend/test_api.py b/backend/test_api.py new file mode 100644 index 00000000..60d12214 --- /dev/null +++ b/backend/test_api.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Unit tests to verify the Storage API endpoints work correctly. +""" + +import unittest +import requests +import json + +class TestStorageAPI(unittest.TestCase): + """Test cases for Storage API endpoints""" + + def setUp(self): + """Set up test configuration""" + self.base_url = "http://localhost:5001" + + def test_root_endpoint(self): + """Test the root endpoint""" + response = requests.get(f"{self.base_url}/") + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.json(), dict) + + def test_storage_entry_operations(self): + """Test storage entry endpoints""" + # Save an entry + response = requests.post(f"{self.base_url}/entries/test_key", + json={"value": "test_value"}) + self.assertEqual(response.status_code, 200) + + # Fetch the entry + response = requests.get(f"{self.base_url}/entries/test_key") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("value", data) + + # Test default value for missing key + response = requests.get(f"{self.base_url}/entries/missing_key?default=default_val") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("value", data) + + def test_storage_file_operations(self): + """Test storage file endpoints""" + # Save a file + response = requests.post(f"{self.base_url}/storage/test/example.txt", + json={"content": "Hello, World!"}) + self.assertEqual(response.status_code, 200) + + # Fetch the file + response = requests.get(f"{self.base_url}/storage/test/example.txt") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("content", data) + + # List files + response = requests.get(f"{self.base_url}/storage/test/") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIsInstance(data, (list, dict)) + + # Make sure directory shows up + response = requests.get(f"{self.base_url}/storage/") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIsInstance(data, (list, dict)) + + def test_storage_rename_operation(self): + """Test storage rename endpoint""" + # Create a test file + response = requests.post(f"{self.base_url}/storage/test_rename_file.txt", + json={"content": "Content to rename"}) + self.assertEqual(response.status_code, 200) + + # Rename the file + response = requests.post(f"{self.base_url}/storage/rename", + json={"old_path": "test_rename_file.txt", "new_path": "renamed_test_file.txt"}) + self.assertEqual(response.status_code, 200) + + # Verify old file is gone + response = requests.get(f"{self.base_url}/storage/test_rename_file.txt") + self.assertEqual(response.status_code, 404) + + # Verify new file exists + response = requests.get(f"{self.base_url}/storage/renamed_test_file.txt") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("content", data) + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/src/storage/server_side_storage.ts b/src/storage/server_side_storage.ts new file mode 100644 index 00000000..379cc1fb --- /dev/null +++ b/src/storage/server_side_storage.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2025 Porpoiseful LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * @fileoverview This implements the server side storage + * @author alan@porpoiseful.com (Alan Smith) + */ + +import * as commonStorage from './common_storage'; + +const API_BASE_URL = 'http://localhost:5001'; + +export async function isServerAvailable(): Promise { + try { + // Create a timeout promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Timeout')), 5000); // 5 second timeout + }); + + // Race between the fetch and timeout + const response = await Promise.race([ + fetch(`${API_BASE_URL}/`), + timeoutPromise + ]); + + return response.ok; + } catch (error) { + // Network error, server not available, or timeout + return false; + } +} + +export class ServerSideStorage implements commonStorage.Storage { + + async saveEntry(entryKey: string, entryValue: string): Promise { + const response = await fetch(`${API_BASE_URL}/entries/${encodeURIComponent(entryKey)}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ value: entryValue }), + }); + + if (!response.ok) { + throw new Error(`Failed to save entry: ${response.statusText}`); + } + } + + async fetchEntry(entryKey: string, defaultValue: string): Promise { + const url = `${API_BASE_URL}/entries/${encodeURIComponent(entryKey)}?default=${encodeURIComponent(defaultValue)}`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch entry: ${response.statusText}`); + } + + const data = await response.json(); + return data.value || defaultValue; + } + + async list(path: string): Promise { + // Ensure path ends with / for directory listing + const dirPath = path.endsWith('/') ? path : path + '/'; + const response = await fetch(`${API_BASE_URL}/storage/${encodeURIComponent(dirPath)}`); + + if (!response.ok) { + throw new Error(`Failed to list files: ${response.statusText}`); + } + + const data = await response.json(); + return data.files || []; + } + + async rename(oldPath: string, newPath: string): Promise { + const response = await fetch(`${API_BASE_URL}/storage/rename`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ old_path: oldPath, new_path: newPath }), + }); + + if (!response.ok) { + throw new Error(`Failed to rename: ${response.statusText}`); + } + } + + async fetchFileContentText(filePath: string): Promise { + const response = await fetch(`${API_BASE_URL}/storage/${encodeURIComponent(filePath)}`); + + if (!response.ok) { + if (response.status === 404) { + throw new Error(`File not found: ${filePath}`); + } + throw new Error(`Failed to fetch file: ${response.statusText}`); + } + + const data = await response.json(); + return data.content || ''; + } + + async saveFile(filePath: string, fileContentText: string): Promise { + const response = await fetch(`${API_BASE_URL}/storage/${encodeURIComponent(filePath)}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ content: fileContentText }), + }); + + if (!response.ok) { + throw new Error(`Failed to save file: ${response.statusText}`); + } + } + + async delete(path: string): Promise { + const response = await fetch(`${API_BASE_URL}/storage/${encodeURIComponent(path)}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`Failed to delete: ${response.statusText}`); + } + } +} \ No newline at end of file