From 474d302114b4e51a46e6f3dfc136623e94ea7853 Mon Sep 17 00:00:00 2001 From: Jaden Fix <163031992+jadenfix@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:38:25 -0700 Subject: [PATCH] Persist local gem records and verify the Ruby API over real HTTP Use atomic JSON writes and a synchronized create operation; validate required fields and duplicates; refuse fabricated scanner results; omit token values from logs. Validation: syntax check and real HTTP create/read/restart-persistence/authentication/failure check passed with scoped Ruby 3.2.3 and WEBrick. Full Sinatra and frontend integrations remain outside this gate. --- .github/workflows/api-http.yml | 15 ++++++++ .gitignore | 4 +- README.md | 28 ++++++++++---- scripts/check_api_http.py | 67 ++++++++++++++++++++++++++++++++++ services/api/simple_server.rb | 66 +++++++++++++++++++++++---------- 5 files changed, 153 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/api-http.yml create mode 100644 scripts/check_api_http.py mode change 100755 => 100644 services/api/simple_server.rb diff --git a/.github/workflows/api-http.yml b/.github/workflows/api-http.yml new file mode 100644 index 0000000..77d3445 --- /dev/null +++ b/.github/workflows/api-http.yml @@ -0,0 +1,15 @@ +name: Ruby API HTTP persistence +on: [push, pull_request] +permissions: + contents: read +jobs: + http: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.3' + - run: gem install webrick --no-document + - run: ruby -c services/api/simple_server.rb + - run: python3 scripts/check_api_http.py diff --git a/.gitignore b/.gitignore index f92bd7c..79f329d 100644 --- a/.gitignore +++ b/.gitignore @@ -79,4 +79,6 @@ jspm_packages/ .yarn-integrity # dotenv environment variables file -.env \ No newline at end of file +.env +# Local API persistence +services/api/data/ diff --git a/README.md b/README.md index fe9b18b..cc925cd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ GemHub is built as a microservices architecture with the following components: - **CLI Tools**: Thor-based command line interface - **Frontend**: React + TypeScript panels for marketplace, sandbox, benchmarks -### Lane B - Core API & Registry +### Lane B - Core API & Registry - **Sinatra API**: Lightweight Ruby API with SQLite storage - **Gem Registry**: CRUD operations for gem metadata - **Rating System**: Community-driven gem ratings and badges @@ -33,7 +33,7 @@ GemHub is built as a microservices architecture with the following components: FastAPI service providing AI-powered gem ranking and suggestions: - **`/rank`**: Rank gems by relevance to search queries -- **`/suggest`**: Get contextual gem recommendations +- **`/suggest`**: Get contextual gem recommendations - **`/health`**: Service health monitoring **Features:** @@ -94,7 +94,7 @@ FAISS-based semantic search engine: The following services will be available: - **API Server**: http://localhost:4567 -- **LLM Gateway**: http://localhost:8001 +- **LLM Gateway**: http://localhost:8001 - **Vector Store**: http://localhost:8002 - **CVE Scanner**: http://localhost:8003 @@ -131,7 +131,7 @@ make llm-test ```bash make help # Show all available commands -make dev # Start development environment +make dev # Start development environment make build # Build all Docker images make test # Run test suite make clean # Clean up containers and volumes @@ -157,7 +157,7 @@ curl -X POST http://localhost:8001/rank \ "stars": 55000 }, { - "name": "sinatra", + "name": "sinatra", "description": "Lightweight web framework", "keywords": ["web", "micro", "api"], "stars": 12000 @@ -252,7 +252,7 @@ gemhub/ ## Lane D Definition of Done Checklist - [x] LLM Gateway (`services/llm_gateway`) - FastAPI app with `/rank`, `/suggest` routes -- [x] Vector Store - FAISS index builds in <30s for 100 docs +- [x] Vector Store - FAISS index builds in <30s for 100 docs - [x] Docker Compose integration - llm_gateway & vector_store services - [x] Makefile target `make embed` to refresh FAISS ⭐ - [ ] Charts render in sidebar with real data (pending Lane A integration) @@ -276,4 +276,18 @@ MIT License - see LICENSE file for details. For issues or questions: - Create GitHub issues for bugs - Check service logs: `make logs` -- Verify environment: `make health` \ No newline at end of file +- Verify environment: `make health` +## Local API persistence check + +The lightweight development API supports authenticated gem creation and reads. +`python3 scripts/check_api_http.py` launches the actual Ruby HTTP server, checks +authorization/invalid/duplicate requests, creates a record, restarts the process, +and verifies the record remains available. Install Ruby and `gem install webrick` +first. `RUBY_BIN` may select a task-specific Ruby interpreter. + +Records persist atomically in `services/api/data/gems.json`; set +`GEMHUB_DATA_PATH` to choose another local file. The server binds loopback by +default; `PORT` and `BIND_ADDRESS` are configurable. Supply `API_TOKEN` for your +local session; the token is never printed. The `/scan` endpoint returns 501 +because no scanner is implemented, rather than reporting fabricated clean scans. +This verifies the lightweight API, not Sinatra/SQLite, sandbox, or benchmark services. diff --git a/scripts/check_api_http.py b/scripts/check_api_http.py new file mode 100644 index 0000000..00c7da5 --- /dev/null +++ b/scripts/check_api_http.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Check the actual Ruby API over loopback HTTP, including file persistence.""" +from pathlib import Path +import json +import os +import socket +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] + +def main(): + with tempfile.TemporaryDirectory() as temporary: + data_path = Path(temporary) / 'gems.json' + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + port = listener.getsockname()[1] + env = {**os.environ, 'PORT': str(port), 'API_TOKEN': 'fixture-token', 'GEMHUB_DATA_PATH': str(data_path)} + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + def request(path, payload=None, authorized=True): + headers = {'Content-Type': 'application/json'} + if authorized: + headers['Authorization'] = 'Bearer fixture-token' + req = urllib.request.Request(f'http://127.0.0.1:{port}{path}', data=json.dumps(payload).encode() if payload is not None else None, headers=headers) + try: + with opener.open(req, timeout=5) as response: + return response.status, json.loads(response.read()) + except urllib.error.HTTPError as error: + return error.code, json.loads(error.read()) + for iteration in range(2): + with open(Path(temporary) / 'server.log', 'a+') as log: + process = subprocess.Popen([env.get('RUBY_BIN', 'ruby'), 'services/api/simple_server.rb'], cwd=ROOT, env=env, stdout=log, stderr=log) + try: + for attempt in range(150): + if process.poll() is not None: + log.seek(0) + raise RuntimeError(log.read()) + try: + if request('/health')[0] == 200: + break + except (OSError, urllib.error.URLError): + time.sleep(.1) + else: + raise RuntimeError('Ruby HTTP startup timeout') + assert request('/gems', authorized=False)[0] == 401 + if iteration == 0: + gem = {'name': 'fixture-http-gem', 'version': '1.0.0', 'description': 'Temporary real API test'} + assert request('/gems', gem)[0] == 201 + assert request('/gems', gem)[0] == 409 + assert request('/gems', {'name': ''})[0] == 400 + assert request('/gems', [1, 2])[0] == 400 + assert request('/scan', {'gem_name': gem['name']})[0] == 501 + records = request('/gems')[1]['gems'] + stored = next(gem for gem in records if gem['name'] == 'fixture-http-gem') + assert request('/gems/' + str(stored['id']))[1]['gem']['version'] == '1.0.0' + assert request('/gems/999999')[0] == 404 + finally: + process.terminate() + process.wait(timeout=10) + assert any(gem['name'] == 'fixture-http-gem' for gem in json.loads(data_path.read_text())) + print('PASS: Ruby HTTP create/read, authorization, validation, duplicates, unavailable scanner, restart persistence') + +if __name__ == '__main__': + main() diff --git a/services/api/simple_server.rb b/services/api/simple_server.rb old mode 100755 new mode 100644 index 1c331f3..252f741 --- a/services/api/simple_server.rb +++ b/services/api/simple_server.rb @@ -2,6 +2,9 @@ require 'webrick' require 'json' require 'cgi' +require 'time' +require 'fileutils' +require 'thread' # Simple in-memory data store $gems = [ @@ -77,6 +80,25 @@ } ] +DATA_PATH = File.expand_path(ENV.fetch('GEMHUB_DATA_PATH', 'data/gems.json'), __dir__) +DATA_LOCK = Mutex.new +if File.exist?(DATA_PATH) + stored = JSON.parse(File.read(DATA_PATH), symbolize_names: true) + raise 'Gem data must be an array' unless stored.is_a?(Array) + $gems = stored +end + +def persist_gems(records) + FileUtils.mkdir_p(File.dirname(DATA_PATH)) + temporary = "#{DATA_PATH}.tmp" + File.open(temporary, 'w', 0600) do |file| + file.write(JSON.generate(records)) + file.flush + file.fsync + end + File.rename(temporary, DATA_PATH) +end + class APIServer < WEBrick::HTTPServlet::AbstractServlet def initialize(server) super @@ -93,7 +115,8 @@ def add_cors_headers(response) def authenticate(request) auth_header = request['Authorization'] return false unless auth_header - token = auth_header.gsub('Bearer ', '') + return false unless auth_header.start_with?('Bearer ') + token = auth_header.delete_prefix('Bearer ') token == @api_token end @@ -162,10 +185,22 @@ def do_POST(request, response) when '/gems' begin data = JSON.parse(request.body) + unless data.is_a?(Hash) && data['name'].is_a?(String) && !data['name'].strip.empty? && + data['version'].is_a?(String) && !data['version'].strip.empty? + response.status = 400 + response.body = JSON.generate({ error: 'name and version are required strings' }) + return + end + DATA_LOCK.synchronize do + if $gems.any? { |gem| gem[:name] == data['name'].strip && gem[:version] == data['version'].strip } + response.status = 409 + response.body = JSON.generate({ error: 'Gem version already exists' }) + return + end new_gem = { id: ($gems.map { |g| g[:id] }.max || 0) + 1, - name: data['name'], - version: data['version'], + name: data['name'].strip, + version: data['version'].strip, description: data['description'] || '', homepage: data['homepage'] || '', license: data['license'] || 'MIT', @@ -176,28 +211,20 @@ def do_POST(request, response) created_at: Time.now.iso8601, updated_at: Time.now.iso8601 } - $gems << new_gem + records = $gems + [new_gem] + persist_gems(records) + $gems = records response.status = 201 response.body = JSON.generate({ gem: new_gem }) + end rescue JSON::ParserError response.status = 400 response.body = JSON.generate({ error: 'Invalid JSON' }) end when '/scan' - begin - data = JSON.parse(request.body) - response.status = 200 - response.body = JSON.generate({ - gem_name: data['gem_name'], - scan_status: 'completed', - vulnerabilities: [], - scan_timestamp: Time.now.iso8601 - }) - rescue JSON::ParserError - response.status = 400 - response.body = JSON.generate({ error: 'Invalid JSON' }) - end + response.status = 501 + response.body = JSON.generate({ error: 'Security scanner is not configured; no vulnerability result is available' }) else response.status = 404 response.body = JSON.generate({ error: 'Not found' }) @@ -207,7 +234,8 @@ def do_POST(request, response) # Start the server server = WEBrick::HTTPServer.new( - Port: 4567, + Port: Integer(ENV.fetch('PORT', '4567')), + BindAddress: ENV.fetch('BIND_ADDRESS', '127.0.0.1'), DocumentRoot: '.', Logger: WEBrick::Log.new($stderr, WEBrick::Log::INFO), AccessLog: [] @@ -222,7 +250,7 @@ def do_POST(request, response) puts "🚀 GemHub API Server starting on http://localhost:4567" puts "📚 API Documentation: http://localhost:4567/docs" puts "💚 Health Check: http://localhost:4567/health" -puts "🔑 API Token: #{ENV['API_TOKEN'] || 'test-token'}" +puts "🔑 Bearer token configured (value omitted)" puts "" puts "Press Ctrl+C to stop the server"