Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/api-http.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,6 @@ jspm_packages/
.yarn-integrity

# dotenv environment variables file
.env
.env
# Local API persistence
services/api/data/
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:**
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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`
- 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.
67 changes: 67 additions & 0 deletions scripts/check_api_http.py
Original file line number Diff line number Diff line change
@@ -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()
66 changes: 47 additions & 19 deletions services/api/simple_server.rb
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
require 'webrick'
require 'json'
require 'cgi'
require 'time'
require 'fileutils'
require 'thread'

# Simple in-memory data store
$gems = [
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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',
Expand All @@ -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' })
Expand All @@ -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: []
Expand All @@ -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"

Expand Down
Loading