"""Public API Blueprint: API key management and public endpoints. Prefix: /api/v1 """ from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn, get_master_conn from services.public_api_engine import ( create_api_key, validate_api_key, check_rate_limit, increment_rate_limit, log_api_request, list_api_keys, revoke_api_key, delete_api_key, ) public_api_bp = Blueprint('public_api', __name__, url_prefix='/api/v1') # ─── Admin endpoints (require auth) ───────────────────────────── @public_api_bp.route('/keys', methods=['GET']) @require_auth() def get_keys(): conn = get_tenant_conn(g.tenant_id) try: keys = list_api_keys(conn, g.tenant_id) return jsonify({'keys': keys}) finally: conn.close() @public_api_bp.route('/keys', methods=['POST']) @require_auth() def create_key(): data = request.get_json() or {} if not data.get('name'): return jsonify({'error': 'name is required'}), 400 conn = get_tenant_conn(g.tenant_id) try: key_id, full_key = create_api_key( conn, g.tenant_id, data['name'], scopes=data.get('scopes', ['read']), rate_limit_rpm=data.get('rate_limit_rpm', 60), rate_limit_rpd=data.get('rate_limit_rpd', 10000), created_by=getattr(g, 'employee_id', None), expires_at=data.get('expires_at'), ) return jsonify({ 'id': key_id, 'api_key': full_key, # Only shown once! 'message': 'Store this key safely — it will not be shown again', }), 201 finally: conn.close() @public_api_bp.route('/keys//revoke', methods=['PUT']) @require_auth() def revoke_key(key_id): conn = get_tenant_conn(g.tenant_id) try: revoke_api_key(conn, key_id) return jsonify({'message': 'API key revoked'}) finally: conn.close() @public_api_bp.route('/keys/', methods=['DELETE']) @require_auth() def delete_key(key_id): conn = get_tenant_conn(g.tenant_id) try: delete_api_key(conn, key_id) return jsonify({'message': 'API key deleted'}) finally: conn.close() # ─── Public endpoints (API key auth) ───────────────────────────── def _require_api_key(): """Decorator-like helper to validate API key from header.""" api_key = request.headers.get('X-API-Key') or request.headers.get('Authorization', '').replace('Bearer ', '') if not api_key: return None, {'error': 'API key required. Provide via X-API-Key header or Authorization: Bearer '}, 401 conn = get_master_conn() try: key_info = validate_api_key(conn, api_key) if not key_info or not key_info.get('valid'): reason = key_info.get('reason', 'invalid') if key_info else 'invalid' return None, {'error': f'API key {reason}'}, 401 # Check rate limit allowed, headers = check_rate_limit(conn, key_info['key_id'], key_info['rate_limit_rpm'], key_info['rate_limit_rpd']) if not allowed: return None, {'error': 'Rate limit exceeded'}, 429, headers increment_rate_limit(conn, key_info['key_id']) return key_info, None, None, headers finally: conn.close() @public_api_bp.route('/health', methods=['GET']) def public_health(): return jsonify({'status': 'ok', 'service': 'Nexus Public API'}) @public_api_bp.route('/catalog/search', methods=['GET']) def public_catalog_search(): key_info, error, status, *extra = _require_api_key() if error: headers = extra[0] if extra else {} return jsonify(error), status, headers q = request.args.get('q', '').strip() limit = min(int(request.args.get('limit', 50)), 200) if not q or len(q) < 2: return jsonify({'error': 'Query must be at least 2 characters'}), 400 start_time = __import__('time').time() conn = get_tenant_conn(key_info['tenant_id']) try: from services.catalog_service import smart_search master = get_master_conn() try: results = smart_search(master, q, conn, branch_id=None, limit=limit) finally: master.close() response_time_ms = int((__import__('time').time() - start_time) * 1000) # Log the request master2 = get_master_conn() try: log_api_request( master2, key_info['key_id'], key_info['tenant_id'], request.method, request.path, 200, response_time_ms, request.remote_addr, request.headers.get('User-Agent'), ) finally: master2.close() headers = extra[0] if extra else {} return jsonify({ 'query': q, 'results': results, 'count': len(results), }), 200, headers finally: conn.close() @public_api_bp.route('/catalog/parts/', methods=['GET']) def public_part_detail(part_id): key_info, error, status, *extra = _require_api_key() if error: headers = extra[0] if extra else {} return jsonify(error), status, headers start_time = __import__('time').time() conn = get_tenant_conn(key_info['tenant_id']) try: cur = conn.cursor() cur.execute(""" SELECT id, part_number, name, brand, price_1, price_2, price_3, image_url, description, is_active FROM inventory WHERE id = %s AND is_active = true """, (part_id,)) row = cur.fetchone() cur.close() if not row: return jsonify({'error': 'Part not found'}), 404 part = { 'id': row[0], 'part_number': row[1], 'name': row[2], 'brand': row[3], 'price_1': float(row[4]) if row[4] else None, 'price_2': float(row[5]) if row[5] else None, 'price_3': float(row[6]) if row[6] else None, 'image_url': row[7], 'description': row[8], 'is_active': row[9], } response_time_ms = int((__import__('time').time() - start_time) * 1000) master = get_master_conn() try: log_api_request( master, key_info['key_id'], key_info['tenant_id'], request.method, request.path, 200, response_time_ms, request.remote_addr, request.headers.get('User-Agent'), ) finally: master.close() headers = extra[0] if extra else {} return jsonify(part), 200, headers finally: conn.close()