nl2sql-api 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. nl2sql_api-0.1.0/PKG-INFO +11 -0
  2. nl2sql_api-0.1.0/README.md +85 -0
  3. nl2sql_api-0.1.0/pyproject.toml +24 -0
  4. nl2sql_api-0.1.0/setup.cfg +4 -0
  5. nl2sql_api-0.1.0/src/nl2sql_api/__init__.py +0 -0
  6. nl2sql_api-0.1.0/src/nl2sql_api/dependencies.py +44 -0
  7. nl2sql_api-0.1.0/src/nl2sql_api/main.py +47 -0
  8. nl2sql_api-0.1.0/src/nl2sql_api/models/__init__.py +0 -0
  9. nl2sql_api-0.1.0/src/nl2sql_api/models/datasource.py +11 -0
  10. nl2sql_api-0.1.0/src/nl2sql_api/models/llm.py +11 -0
  11. nl2sql_api-0.1.0/src/nl2sql_api/models/query.py +33 -0
  12. nl2sql_api-0.1.0/src/nl2sql_api/models/response.py +13 -0
  13. nl2sql_api-0.1.0/src/nl2sql_api/models/schema.py +8 -0
  14. nl2sql_api-0.1.0/src/nl2sql_api/routes/__init__.py +0 -0
  15. nl2sql_api-0.1.0/src/nl2sql_api/routes/datasource.py +57 -0
  16. nl2sql_api-0.1.0/src/nl2sql_api/routes/health.py +22 -0
  17. nl2sql_api-0.1.0/src/nl2sql_api/routes/indexing.py +73 -0
  18. nl2sql_api-0.1.0/src/nl2sql_api/routes/llm.py +41 -0
  19. nl2sql_api-0.1.0/src/nl2sql_api/routes/query.py +29 -0
  20. nl2sql_api-0.1.0/src/nl2sql_api/server.py +30 -0
  21. nl2sql_api-0.1.0/src/nl2sql_api/services/__init__.py +15 -0
  22. nl2sql_api-0.1.0/src/nl2sql_api/services/datasource.py +43 -0
  23. nl2sql_api-0.1.0/src/nl2sql_api/services/health.py +22 -0
  24. nl2sql_api-0.1.0/src/nl2sql_api/services/indexing.py +30 -0
  25. nl2sql_api-0.1.0/src/nl2sql_api/services/llm.py +25 -0
  26. nl2sql_api-0.1.0/src/nl2sql_api/services/query.py +34 -0
  27. nl2sql_api-0.1.0/src/nl2sql_api.egg-info/PKG-INFO +11 -0
  28. nl2sql_api-0.1.0/src/nl2sql_api.egg-info/SOURCES.txt +34 -0
  29. nl2sql_api-0.1.0/src/nl2sql_api.egg-info/dependency_links.txt +1 -0
  30. nl2sql_api-0.1.0/src/nl2sql_api.egg-info/entry_points.txt +2 -0
  31. nl2sql_api-0.1.0/src/nl2sql_api.egg-info/requires.txt +6 -0
  32. nl2sql_api-0.1.0/src/nl2sql_api.egg-info/top_level.txt +1 -0
  33. nl2sql_api-0.1.0/tests/test_app.py +32 -0
  34. nl2sql_api-0.1.0/tests/test_cors.py +72 -0
  35. nl2sql_api-0.1.0/tests/test_query_routes.py +125 -0
  36. nl2sql_api-0.1.0/tests/test_services.py +55 -0
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: nl2sql-api
3
+ Version: 0.1.0
4
+ Summary: API layer for NL2SQL engine
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: nl2sql-engine~=0.1
7
+ Requires-Dist: fastapi>=0.100.0
8
+ Requires-Dist: uvicorn>=0.20.0
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: python-multipart>=0.0.6
11
+ Requires-Dist: python-dotenv>=1.0.0
@@ -0,0 +1,85 @@
1
+ # NL2SQL API
2
+
3
+ API layer for the NL2SQL engine that provides a REST interface to the core functionality.
4
+
5
+ ## Two-Tier API Architecture
6
+
7
+ NL2SQL provides a two-tier API architecture:
8
+
9
+ ### 1. Core API (Python)
10
+ - **Location**: Core package (`nl2sql`)
11
+ - **Interface**: Direct Python class interface (`NL2SQL` class)
12
+ - **Use Case**: Direct Python integration, embedded applications
13
+ - **Access**: Import and use directly in Python code
14
+
15
+ ### 2. REST API (HTTP) - This Package
16
+ - **Location**: API package (`nl2sql-api`) - This package
17
+ - **Interface**: HTTP REST endpoints
18
+ - **Use Case**: Remote clients, web applications, TypeScript CLI
19
+ - **Access**: HTTP requests to API endpoints
20
+
21
+ This REST API package serves as a bridge between external HTTP clients and the core NL2SQL engine, using the core's public API internally.
22
+
23
+ ## Overview
24
+
25
+ This package provides a FastAPI-based REST API that uses the NL2SQL core's public API to interact with the NL2SQL engine over HTTP. It serves as a bridge between external clients (such as the TypeScript CLI) and the core engine functionality.
26
+
27
+ ## Architecture
28
+
29
+ The API package leverages the NL2SQL core's public API layer (`NL2SQL` class), ensuring clean separation between the API service and the core engine implementation. The service layer uses the core's public methods like `run_query()`, `list_datasources()`, and schema access through `engine.context.schema_store`.
30
+
31
+ ## Features
32
+
33
+ - RESTful API endpoints for natural language to SQL conversion
34
+ - Datasource and LLM management endpoints
35
+ - Health and readiness checks
36
+ - Proper error handling and response formatting
37
+ - Lazy initialization to avoid configuration issues during import
38
+ - Integration with the core's public API layer
39
+
40
+ ## Endpoints
41
+
42
+ ### Query Endpoints
43
+ - `POST /api/v1/query` - Execute a natural language query
44
+ - `GET /api/v1/health` - Health check endpoint
45
+ - `GET /api/v1/ready` - Readiness check endpoint
46
+
47
+ ### Datasource Management Endpoints
48
+ - `POST /api/v1/datasource` - Add a new datasource programmatically
49
+ - `GET /api/v1/datasource` - List all registered datasources
50
+ - `GET /api/v1/datasource/{datasource_id}` - Get details of a specific datasource
51
+ - `DELETE /api/v1/datasource/{datasource_id}` - Remove a datasource (not currently supported)
52
+
53
+ ### LLM Management Endpoints
54
+ - `POST /api/v1/llm` - Configure an LLM programmatically
55
+ - `GET /api/v1/llm` - List all configured LLMs
56
+ - `GET /api/v1/llm/{llm_name}` - Get details of a specific LLM
57
+
58
+ ### Indexing Management Endpoints
59
+ - `POST /api/v1/index/{datasource_id}` - Index schema for a specific datasource
60
+ - `POST /api/v1/index-all` - Index schema for all registered datasources
61
+ - `DELETE /api/v1/index` - Clear the vector store index
62
+ - `GET /api/v1/index/status` - Get the status of the index
63
+
64
+ ## Running the API
65
+
66
+ ```bash
67
+ pip install -e .
68
+ nl2sql-api --host 0.0.0.0 --port 8000 --reload
69
+ ```
70
+
71
+ Or using the server script directly:
72
+
73
+ ```bash
74
+ python -m nl2sql_api.server --host 0.0.0.0 --port 8000 --reload
75
+ ```
76
+
77
+ ## Development
78
+
79
+ Install in development mode:
80
+
81
+ ```bash
82
+ pip install -e .
83
+ ```
84
+
85
+ For detailed API documentation, see [API_DOCS.md](API_DOCS.md).
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nl2sql-api"
7
+ version = "0.1.0" # x-release-please-version
8
+ description = "API layer for NL2SQL engine"
9
+ requires-python = ">=3.9"
10
+ dependencies = [
11
+ "nl2sql-engine~=0.1",
12
+ "fastapi>=0.100.0",
13
+ "uvicorn>=0.20.0",
14
+ "pydantic>=2.0",
15
+ "python-multipart>=0.0.6",
16
+ "python-dotenv>=1.0.0"
17
+ ]
18
+
19
+ [project.scripts]
20
+ nl2sql-api = "nl2sql_api.server:main"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+ include = ["nl2sql_api*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,44 @@
1
+ from fastapi import Depends, Request
2
+
3
+ from nl2sql import NL2SQL
4
+ from nl2sql_api.services import (
5
+ DatasourceService,
6
+ QueryService,
7
+ LLMService,
8
+ IndexingService,
9
+ HealthService,
10
+ )
11
+
12
+
13
+ def get_engine(request: Request) -> NL2SQL:
14
+ return request.app.state.engine
15
+
16
+
17
+ def get_datasource_service(
18
+ engine: NL2SQL = Depends(get_engine),
19
+ ) -> DatasourceService:
20
+ return DatasourceService(engine)
21
+
22
+
23
+ def get_query_service(
24
+ engine: NL2SQL = Depends(get_engine),
25
+ ) -> QueryService:
26
+ return QueryService(engine)
27
+
28
+
29
+ def get_llm_service(
30
+ engine: NL2SQL = Depends(get_engine),
31
+ ) -> LLMService:
32
+ return LLMService(engine)
33
+
34
+
35
+ def get_indexing_service(
36
+ engine: NL2SQL = Depends(get_engine),
37
+ ) -> IndexingService:
38
+ return IndexingService(engine)
39
+
40
+
41
+ def get_health_service(
42
+ engine: NL2SQL = Depends(get_engine),
43
+ ) -> HealthService:
44
+ return HealthService(engine)
@@ -0,0 +1,47 @@
1
+ import os
2
+
3
+ from fastapi import FastAPI
4
+ from contextlib import asynccontextmanager
5
+
6
+ from .routes import query, health, datasource, llm, indexing
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+ from nl2sql import NL2SQL
9
+ from nl2sql.common.logger import configure_logging
10
+ from nl2sql.common.settings import settings
11
+
12
+
13
+ @asynccontextmanager
14
+ async def lifespan(app: FastAPI):
15
+ # The library no longer configures logging on import, so the application
16
+ # entry point owns it - before anything that logs is constructed.
17
+ configure_logging(
18
+ level="INFO",
19
+ json_format=(settings.observability_exporter == "otlp"),
20
+ )
21
+ app.state.engine = NL2SQL()
22
+ yield
23
+
24
+
25
+ app = FastAPI(
26
+ title="NL2SQL API",
27
+ version="0.1.0",
28
+ lifespan=lifespan,
29
+ )
30
+
31
+ app.include_router(query.router, prefix="/api/v1")
32
+ app.include_router(health.router, prefix="/api/v1")
33
+ app.include_router(datasource.router, prefix="/api/v1")
34
+ app.include_router(llm.router, prefix="/api/v1")
35
+ app.include_router(indexing.router, prefix="/api/v1")
36
+
37
+ _origins = [o.strip() for o in os.getenv("NL2SQL_API_CORS_ORIGINS", "").split(",") if o.strip()]
38
+
39
+ app.add_middleware(
40
+ CORSMiddleware,
41
+ allow_origins=_origins,
42
+ allow_credentials=bool(_origins),
43
+ allow_methods=["*"],
44
+ allow_headers=["*"],
45
+ )
46
+
47
+
File without changes
@@ -0,0 +1,11 @@
1
+ from pydantic import BaseModel
2
+ from typing import Dict, Any, Optional
3
+
4
+ class DatasourceRequest(BaseModel):
5
+ config: Dict[str, Any]
6
+
7
+
8
+ class DatasourceResponse(BaseModel):
9
+ success: bool
10
+ message: str
11
+ datasource_id: Optional[str] = None
@@ -0,0 +1,11 @@
1
+ from pydantic import BaseModel
2
+ from typing import Dict, Any, Optional
3
+
4
+ class LLMRequest(BaseModel):
5
+ config: Dict[str, Any]
6
+
7
+
8
+ class LLMResponse(BaseModel):
9
+ success: bool
10
+ message: str
11
+ llm_name: Optional[str] = None
@@ -0,0 +1,33 @@
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional, Dict, Any, List
3
+
4
+
5
+ class QueryRequest(BaseModel):
6
+ natural_language: str
7
+ datasource_id: Optional[str] = None
8
+ execute: bool = True
9
+ user_context: Optional[Dict[str, Any]] = None
10
+
11
+
12
+ class SubQueryResponse(BaseModel):
13
+ """One decomposed sub-query and the SQL generated for it."""
14
+ id: str = ""
15
+ intent: str = ""
16
+ sql: str = ""
17
+ datasource_id: str = ""
18
+ schema_version: str = ""
19
+
20
+
21
+ class QueryResponse(BaseModel):
22
+ """Mirrors ``nl2sql.api.query_api.QueryResult``.
23
+
24
+ Result rows are not inlined; they live in artifact storage and are addressable
25
+ through ``artifact_refs``.
26
+ """
27
+ sub_queries: List[SubQueryResponse] = Field(default_factory=list)
28
+ final_answer: Optional[Dict[str, Any]] = None
29
+ errors: List[Dict[str, Any]] = Field(default_factory=list)
30
+ trace_id: Optional[str] = None
31
+ reasoning: List[Dict[str, Any]] = Field(default_factory=list)
32
+ warnings: List[Dict[str, Any]] = Field(default_factory=list)
33
+ artifact_refs: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List, Dict, Any
3
+
4
+ class ErrorResponse(BaseModel):
5
+ error_code: str
6
+ message: str
7
+ details: Optional[Dict[str, Any]] = None
8
+
9
+
10
+ class SuccessResponse(BaseModel):
11
+ success: bool
12
+ data: Optional[Dict[str, Any]] = None
13
+ message: Optional[str] = None
@@ -0,0 +1,8 @@
1
+ from pydantic import BaseModel
2
+ from typing import Optional, List, Dict, Any
3
+
4
+ class SchemaResponse(BaseModel):
5
+ datasource_id: str
6
+ tables: List[Dict[str, Any]]
7
+ relationships: List[Dict[str, Any]]
8
+ metadata: Optional[Dict[str, Any]] = None
File without changes
@@ -0,0 +1,57 @@
1
+ from fastapi import APIRouter, HTTPException, Depends
2
+ from typing import Dict, Any, Annotated
3
+
4
+ from nl2sql_api.models.datasource import DatasourceRequest, DatasourceResponse
5
+ from nl2sql_api.dependencies import get_datasource_service
6
+ from nl2sql_api.services import DatasourceService
7
+
8
+ router = APIRouter()
9
+
10
+ DatasourceSvc = Annotated[DatasourceService, Depends(get_datasource_service)]
11
+
12
+
13
+ @router.post("/datasource", response_model=DatasourceResponse)
14
+ def add_datasource(
15
+ payload: DatasourceRequest,
16
+ service: DatasourceSvc,
17
+ ):
18
+ try:
19
+ return service.add_datasource(payload)
20
+ except Exception as e:
21
+ raise HTTPException(status_code=500, detail=str(e))
22
+
23
+
24
+ @router.get("/datasource", response_model=Dict[str, Any])
25
+ def list_datasources(
26
+ service: DatasourceSvc,
27
+ ):
28
+ try:
29
+ return {"datasources": service.list_datasources()}
30
+ except Exception as e:
31
+ raise HTTPException(status_code=500, detail=str(e))
32
+
33
+
34
+ @router.get("/datasource/{datasource_id}", response_model=Dict[str, Any])
35
+ def get_datasource(
36
+ datasource_id: str,
37
+ service: DatasourceSvc,
38
+ ):
39
+ try:
40
+ return service.get_datasource(datasource_id)
41
+ except ValueError as e:
42
+ raise HTTPException(status_code=404, detail=str(e))
43
+ except Exception as e:
44
+ raise HTTPException(status_code=500, detail=str(e))
45
+
46
+
47
+ @router.delete("/datasource/{datasource_id}", response_model=Dict[str, Any])
48
+ def remove_datasource(
49
+ datasource_id: str,
50
+ service: DatasourceSvc,
51
+ ):
52
+ try:
53
+ return service.remove_datasource(datasource_id)
54
+ except ValueError as e:
55
+ raise HTTPException(status_code=404, detail=str(e))
56
+ except Exception as e:
57
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,22 @@
1
+ from fastapi import APIRouter, Depends
2
+ from typing import Annotated
3
+ from nl2sql_api.models.response import SuccessResponse
4
+ from nl2sql_api.dependencies import get_health_service
5
+ from nl2sql_api.services import HealthService
6
+
7
+ router = APIRouter()
8
+
9
+ HealthSvc = Annotated[HealthService, Depends(get_health_service)]
10
+
11
+ @router.get("/health", response_model=SuccessResponse)
12
+ async def health_check(
13
+ service: HealthSvc
14
+ ):
15
+ return service.health_check()
16
+
17
+
18
+ @router.get("/ready", response_model=SuccessResponse)
19
+ async def readiness_check(
20
+ service: HealthSvc,
21
+ ):
22
+ return service.readiness_check()
@@ -0,0 +1,73 @@
1
+ from fastapi import APIRouter, HTTPException, Depends
2
+ from typing import Dict, Any, Annotated
3
+
4
+ from nl2sql_api.dependencies import get_indexing_service
5
+ from nl2sql_api.services import IndexingService
6
+ router = APIRouter()
7
+
8
+ IndexingSvc = Annotated[IndexingService, Depends(get_indexing_service)]
9
+
10
+ @router.post("/index/{datasource_id}", response_model=Dict[str, Any])
11
+ def index_datasource(
12
+ datasource_id: str,
13
+ service: IndexingSvc
14
+ ):
15
+ try:
16
+ result = service.index_datasource(datasource_id)
17
+
18
+ return {
19
+ "success": True,
20
+ "datasource_id": datasource_id,
21
+ "indexing_stats": result,
22
+ "message": f"Successfully indexed datasource '{datasource_id}'",
23
+ }
24
+ except Exception as e:
25
+ raise HTTPException(
26
+ status_code=500,
27
+ detail=f"Failed to index datasource '{datasource_id}': {str(e)}",
28
+ )
29
+
30
+
31
+ @router.post("/index-all", response_model=Dict[str, Any])
32
+ def index_all_datasources(
33
+ service: IndexingSvc
34
+ ):
35
+ try:
36
+ results = service.index_all_datasources()
37
+
38
+ return {
39
+ "success": True,
40
+ "indexing_results": results,
41
+ "message": "Successfully initiated indexing for all datasources",
42
+ }
43
+ except Exception as e:
44
+ raise HTTPException(
45
+ status_code=500,
46
+ detail=f"Failed to index all datasources: {str(e)}",
47
+ )
48
+
49
+
50
+ @router.delete("/index", response_model=Dict[str, Any])
51
+ def clear_index(
52
+ service: IndexingSvc
53
+ ):
54
+ try:
55
+ return service.clear_index()
56
+ except Exception as e:
57
+ raise HTTPException(
58
+ status_code=500,
59
+ detail=f"Failed to clear index: {str(e)}",
60
+ )
61
+
62
+
63
+ @router.get("/index/status", response_model=Dict[str, Any])
64
+ def get_index_status(
65
+ service: IndexingSvc
66
+ ):
67
+ try:
68
+ return service.get_index_status()
69
+ except Exception as e:
70
+ raise HTTPException(
71
+ status_code=500,
72
+ detail=f"Failed to get index status: {str(e)}",
73
+ )
@@ -0,0 +1,41 @@
1
+ from fastapi import APIRouter, HTTPException, Depends
2
+ from typing import Dict, Any, Annotated
3
+
4
+ from nl2sql_api.models.llm import LLMRequest, LLMResponse
5
+ from nl2sql_api.dependencies import get_llm_service
6
+ from nl2sql_api.services import LLMService
7
+
8
+ router = APIRouter()
9
+
10
+ LLMSvc = Annotated[LLMService, Depends(get_llm_service)]
11
+
12
+ @router.post("/llm", response_model=LLMResponse)
13
+ def configure_llm(
14
+ payload: LLMRequest,
15
+ service: LLMSvc,
16
+ ):
17
+ try:
18
+ return service.configure_llm(payload)
19
+ except Exception as e:
20
+ raise HTTPException(status_code=500, detail=str(e))
21
+
22
+
23
+ @router.get("/llm", response_model=Dict[str, Any])
24
+ def list_llms(
25
+ service: LLMSvc,
26
+ ):
27
+ try:
28
+ return {"llms": service.list_llms()}
29
+ except Exception as e:
30
+ raise HTTPException(status_code=500, detail=str(e))
31
+
32
+
33
+ @router.get("/llm/{llm_name}", response_model=Dict[str, Any])
34
+ def get_llm(
35
+ llm_name: str,
36
+ service: LLMSvc,
37
+ ):
38
+ try:
39
+ return service.get_llm(llm_name)
40
+ except Exception as e:
41
+ raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,29 @@
1
+ import logging
2
+
3
+ from fastapi import APIRouter, HTTPException, Depends
4
+ from typing import Annotated
5
+ from nl2sql_api.models.query import QueryRequest, QueryResponse
6
+ from nl2sql_api.dependencies import get_query_service
7
+ from nl2sql_api.services import QueryService
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ router = APIRouter()
12
+
13
+ QuerySvc = Annotated[QueryService, Depends(get_query_service)]
14
+
15
+
16
+ # Synchronous on purpose: the pipeline performs blocking LLM and database calls,
17
+ # so Starlette runs this handler in its threadpool instead of on the event loop.
18
+ @router.post("/query", response_model=QueryResponse)
19
+ def execute_query(
20
+ payload: QueryRequest,
21
+ service: QuerySvc,
22
+ ):
23
+ try:
24
+ return service.execute_query(payload)
25
+ except Exception:
26
+ # Pipeline failures are reported in QueryResponse.errors with a 200; reaching
27
+ # here means something genuinely unexpected broke.
28
+ logger.exception("Unexpected failure while executing query")
29
+ raise HTTPException(status_code=500, detail="Failed to execute query.")
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ NL2SQL API Startup Script
4
+
5
+ This script starts the NL2SQL API server.
6
+ """
7
+
8
+ import argparse
9
+ import sys
10
+ from nl2sql_api.main import app
11
+ import uvicorn
12
+
13
+ def main():
14
+ parser = argparse.ArgumentParser(description='NL2SQL API Server')
15
+ parser.add_argument('--host', default='127.0.0.1', help='Host to bind to (default: 127.0.0.1)')
16
+ parser.add_argument('--port', type=int, default=8000, help='Port to bind to (default: 8000)')
17
+ parser.add_argument('--reload', action='store_true', help='Enable auto-reload (development)')
18
+
19
+ args = parser.parse_args()
20
+
21
+ uvicorn.run(
22
+ 'nl2sql_api.main:app',
23
+ host=args.host,
24
+ port=args.port,
25
+ reload=args.reload,
26
+ log_level="info"
27
+ )
28
+
29
+ if __name__ == '__main__':
30
+ main()
@@ -0,0 +1,15 @@
1
+
2
+ from .datasource import DatasourceService
3
+ from .llm import LLMService
4
+ from .health import HealthService
5
+ from .query import QueryService
6
+ from .indexing import IndexingService
7
+
8
+
9
+ __all__ = [
10
+ "DatasourceService",
11
+ "LLMService",
12
+ "HealthService",
13
+ "QueryService",
14
+ "IndexingService"
15
+ ]
@@ -0,0 +1,43 @@
1
+ from typing import Dict, Any, Optional
2
+
3
+ from nl2sql import NL2SQL
4
+ from nl2sql_api.models.datasource import DatasourceRequest, DatasourceResponse
5
+
6
+
7
+ class DatasourceService:
8
+ def __init__(self, engine: NL2SQL):
9
+ self.engine = engine
10
+
11
+ def add_datasource(self, request: DatasourceRequest) -> DatasourceResponse:
12
+ """Add a new datasource programmatically."""
13
+ self.engine.add_datasource(request.config)
14
+ return DatasourceResponse(
15
+ success=True,
16
+ message=f"Datasource '{request.config.get('id')}' added successfully",
17
+ datasource_id=request.config.get('id')
18
+ )
19
+
20
+ def list_datasources(self) -> list:
21
+ """List all registered datasources."""
22
+ return self.engine.list_datasources()
23
+
24
+ def get_datasource(self, datasource_id: str) -> dict:
25
+ """Get details of a specific datasource."""
26
+ datasource_ids = self.engine.list_datasources()
27
+ if datasource_id not in datasource_ids:
28
+ raise ValueError(f"Datasource '{datasource_id}' not found")
29
+
30
+ # In a real implementation, we would return detailed information about the datasource
31
+ return {"datasource_id": datasource_id, "exists": True}
32
+
33
+ def remove_datasource(self, datasource_id: str) -> dict:
34
+ """Remove a datasource (not directly supported by the engine, but could be implemented)."""
35
+ datasource_ids = self.engine.list_datasources()
36
+ if datasource_id not in datasource_ids:
37
+ raise ValueError(f"Datasource '{datasource_id}' not found")
38
+
39
+ return {
40
+ "success": False,
41
+ "message": "Removing datasources is not currently supported by the engine",
42
+ "datasource_id": datasource_id
43
+ }
@@ -0,0 +1,22 @@
1
+ from typing import Dict, Any, Optional
2
+ from nl2sql import NL2SQL
3
+
4
+
5
+ class HealthService:
6
+ def __init__(self, engine: NL2SQL):
7
+ self.engine = engine
8
+
9
+ def health_check(self) -> dict:
10
+ """Perform health check."""
11
+ return {
12
+ "success": True,
13
+ "message": "NL2SQL API is running"
14
+ }
15
+
16
+ def readiness_check(self) -> dict:
17
+ """Perform readiness check."""
18
+ # Add actual readiness checks (database connections, etc.)
19
+ return {
20
+ "success": True,
21
+ "message": "NL2SQL API is ready"
22
+ }
@@ -0,0 +1,30 @@
1
+ from typing import Dict, Any, Optional
2
+ from nl2sql import NL2SQL
3
+
4
+
5
+ class IndexingService:
6
+ def __init__(self, engine: NL2SQL):
7
+ self.engine = engine
8
+
9
+ def index_datasource(self, datasource_id: str) -> Dict[str, int]:
10
+ """Index schema for a specific datasource."""
11
+ return self.engine.indexing.index_datasource(datasource_id)
12
+
13
+ def index_all_datasources(self) -> Dict[str, Dict[str, int]]:
14
+ """Index schema for all registered datasources."""
15
+ return self.engine.indexing.index_all_datasources()
16
+
17
+ def clear_index(self) -> None:
18
+ """Clear the vector store index."""
19
+ self.engine.indexing.clear_index()
20
+ return {"success": True, "message": "Index cleared successfully"}
21
+
22
+ def get_index_status(self) -> Dict[str, Any]:
23
+ """Get the status of the index."""
24
+ # This would typically return information about the vector store
25
+ # For now, we'll return a placeholder
26
+ return {
27
+ "status": "operational",
28
+ "indexed_datasources": self.engine.list_datasources(),
29
+ "total_indexes": len(self.engine.list_datasources()) # Placeholder
30
+ }
@@ -0,0 +1,25 @@
1
+ from typing import Dict, Any, Optional
2
+ from nl2sql import NL2SQL
3
+ from nl2sql_api.models.llm import LLMRequest, LLMResponse
4
+
5
+
6
+ class LLMService:
7
+ def __init__(self, engine: NL2SQL):
8
+ self.engine = engine
9
+
10
+ def configure_llm(self, request: LLMRequest) -> LLMResponse:
11
+ """Configure an LLM programmatically."""
12
+ self.engine.configure_llm(request.config)
13
+ return LLMResponse(
14
+ success=True,
15
+ message=f"LLM '{request.config.get('name', 'default')}' configured successfully",
16
+ llm_name=request.config.get('name', 'default')
17
+ )
18
+
19
+ def list_llms(self) -> list:
20
+ """List all configured LLMs."""
21
+ return self.engine.list_llms()
22
+
23
+ def get_llm(self, llm_name: str) -> dict:
24
+ """Get details of a specific LLM."""
25
+ return self.engine.get_llm(llm_name)
@@ -0,0 +1,34 @@
1
+ from nl2sql import NL2SQL
2
+ from nl2sql_api.models.query import QueryRequest, QueryResponse
3
+ from nl2sql.auth.models import UserContext
4
+
5
+
6
+ class QueryService:
7
+ def __init__(self, engine: NL2SQL):
8
+ self.engine = engine
9
+
10
+ def execute_query(self, request: QueryRequest) -> QueryResponse:
11
+ # Convert user_context if provided
12
+ user_context = None
13
+ if request.user_context:
14
+ user_context = UserContext(**request.user_context)
15
+
16
+ result = self.engine.run_query(
17
+ request.natural_language,
18
+ datasource_id=request.datasource_id,
19
+ execute=request.execute,
20
+ user_context=user_context,
21
+ )
22
+
23
+ return QueryResponse(
24
+ sub_queries=[sub_query.model_dump() for sub_query in result.sub_queries],
25
+ final_answer=result.final_answer,
26
+ errors=result.errors,
27
+ trace_id=result.trace_id,
28
+ reasoning=result.reasoning,
29
+ warnings=result.warnings,
30
+ artifact_refs={
31
+ node_id: ref.model_dump(mode="json")
32
+ for node_id, ref in result.artifact_refs.items()
33
+ },
34
+ )
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: nl2sql-api
3
+ Version: 0.1.0
4
+ Summary: API layer for NL2SQL engine
5
+ Requires-Python: >=3.9
6
+ Requires-Dist: nl2sql-engine~=0.1
7
+ Requires-Dist: fastapi>=0.100.0
8
+ Requires-Dist: uvicorn>=0.20.0
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: python-multipart>=0.0.6
11
+ Requires-Dist: python-dotenv>=1.0.0
@@ -0,0 +1,34 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/nl2sql_api/__init__.py
4
+ src/nl2sql_api/dependencies.py
5
+ src/nl2sql_api/main.py
6
+ src/nl2sql_api/server.py
7
+ src/nl2sql_api.egg-info/PKG-INFO
8
+ src/nl2sql_api.egg-info/SOURCES.txt
9
+ src/nl2sql_api.egg-info/dependency_links.txt
10
+ src/nl2sql_api.egg-info/entry_points.txt
11
+ src/nl2sql_api.egg-info/requires.txt
12
+ src/nl2sql_api.egg-info/top_level.txt
13
+ src/nl2sql_api/models/__init__.py
14
+ src/nl2sql_api/models/datasource.py
15
+ src/nl2sql_api/models/llm.py
16
+ src/nl2sql_api/models/query.py
17
+ src/nl2sql_api/models/response.py
18
+ src/nl2sql_api/models/schema.py
19
+ src/nl2sql_api/routes/__init__.py
20
+ src/nl2sql_api/routes/datasource.py
21
+ src/nl2sql_api/routes/health.py
22
+ src/nl2sql_api/routes/indexing.py
23
+ src/nl2sql_api/routes/llm.py
24
+ src/nl2sql_api/routes/query.py
25
+ src/nl2sql_api/services/__init__.py
26
+ src/nl2sql_api/services/datasource.py
27
+ src/nl2sql_api/services/health.py
28
+ src/nl2sql_api/services/indexing.py
29
+ src/nl2sql_api/services/llm.py
30
+ src/nl2sql_api/services/query.py
31
+ tests/test_app.py
32
+ tests/test_cors.py
33
+ tests/test_query_routes.py
34
+ tests/test_services.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nl2sql-api = nl2sql_api.server:main
@@ -0,0 +1,6 @@
1
+ nl2sql-engine~=0.1
2
+ fastapi>=0.100.0
3
+ uvicorn>=0.20.0
4
+ pydantic>=2.0
5
+ python-multipart>=0.0.6
6
+ python-dotenv>=1.0.0
@@ -0,0 +1 @@
1
+ nl2sql_api
@@ -0,0 +1,32 @@
1
+ """App wiring tests (ported from packages/api/test_api.py)."""
2
+
3
+ import inspect
4
+
5
+ from nl2sql_api import dependencies
6
+ from nl2sql_api.main import app
7
+ from nl2sql_api.routes import datasource, indexing, llm, query
8
+
9
+
10
+ def test_app_is_constructed():
11
+ assert app is not None
12
+ assert app.title == "NL2SQL API"
13
+
14
+
15
+ def test_dependency_providers_are_callable():
16
+ assert callable(dependencies.get_engine)
17
+ assert callable(dependencies.get_query_service)
18
+
19
+
20
+ def test_health_endpoint_is_registered():
21
+ paths = app.openapi()["paths"]
22
+ assert "/api/v1/health" in paths
23
+ assert "/api/v1/query" in paths
24
+
25
+
26
+ def test_routes_doing_blocking_work_are_synchronous():
27
+ """`async def` handlers around blocking calls would serialise the whole API."""
28
+ for module in (query, datasource, llm, indexing):
29
+ for name, func in vars(module).items():
30
+ if name.startswith("_") or not inspect.isfunction(func):
31
+ continue
32
+ assert not inspect.iscoroutinefunction(func), f"{module.__name__}.{name} is async"
@@ -0,0 +1,72 @@
1
+ """CORS configuration tests.
2
+
3
+ The CORS middleware is attached at import time, so each case reloads
4
+ ``nl2sql_api.main`` with the environment it wants and restores the module to its
5
+ default (no configured origins) afterwards.
6
+ """
7
+
8
+ import importlib
9
+
10
+ import pytest
11
+ from fastapi.testclient import TestClient
12
+
13
+ import nl2sql_api.main
14
+
15
+ ENV_VAR = "NL2SQL_API_CORS_ORIGINS"
16
+
17
+
18
+ @pytest.fixture
19
+ def app_with_cors_env(monkeypatch):
20
+ """Reload the app under a given ``NL2SQL_API_CORS_ORIGINS`` value."""
21
+ clients = []
22
+
23
+ def _make(origins):
24
+ if origins is None:
25
+ monkeypatch.delenv(ENV_VAR, raising=False)
26
+ else:
27
+ monkeypatch.setenv(ENV_VAR, origins)
28
+ module = importlib.reload(nl2sql_api.main)
29
+ client = TestClient(module.app)
30
+ clients.append(client)
31
+ return client
32
+
33
+ yield _make
34
+
35
+ for client in clients:
36
+ client.close()
37
+ # Restore the module to the default configuration for any later test.
38
+ monkeypatch.delenv(ENV_VAR, raising=False)
39
+ importlib.reload(nl2sql_api.main)
40
+
41
+
42
+ def _preflight(client, origin):
43
+ return client.options(
44
+ "/api/v1/health",
45
+ headers={"Origin": origin, "Access-Control-Request-Method": "POST"},
46
+ )
47
+
48
+
49
+ def test_no_configured_origins_rejects_arbitrary_origin(app_with_cors_env):
50
+ """Wildcard-with-credentials is unsafe; unset means no cross-origin access."""
51
+ client = app_with_cors_env(None)
52
+
53
+ response = _preflight(client, "https://evil.example")
54
+
55
+ assert response.headers.get("access-control-allow-origin") != "*"
56
+ assert response.headers.get("access-control-allow-origin") is None
57
+
58
+
59
+ def test_configured_origin_is_echoed_back(app_with_cors_env):
60
+ client = app_with_cors_env("https://bi.corp.example")
61
+
62
+ response = _preflight(client, "https://bi.corp.example")
63
+
64
+ assert response.headers.get("access-control-allow-origin") == "https://bi.corp.example"
65
+
66
+
67
+ def test_unconfigured_origin_is_rejected_when_others_are_allowed(app_with_cors_env):
68
+ client = app_with_cors_env("https://bi.corp.example")
69
+
70
+ response = _preflight(client, "https://evil.example")
71
+
72
+ assert response.headers.get("access-control-allow-origin") is None
@@ -0,0 +1,125 @@
1
+ from datetime import datetime, timezone
2
+
3
+ from nl2sql.api.query_api import QueryResult, SubQueryResult
4
+ from nl2sql.execution.contracts import ArtifactRef
5
+
6
+
7
+ def _artifact_ref() -> ArtifactRef:
8
+ return ArtifactRef(
9
+ uri="file:///artifacts/sq-1.parquet",
10
+ backend="local",
11
+ format="parquet",
12
+ row_count=2,
13
+ columns=["region", "revenue"],
14
+ bytes=512,
15
+ content_hash="abc123",
16
+ created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
17
+ path_template="{trace_id}/{node_id}",
18
+ )
19
+
20
+
21
+ def _query_result() -> QueryResult:
22
+ return QueryResult(
23
+ sub_queries=[
24
+ SubQueryResult(
25
+ id="sq-1",
26
+ intent="total revenue by region",
27
+ sql="SELECT region, SUM(revenue) FROM sales GROUP BY region",
28
+ datasource_id="warehouse",
29
+ schema_version="v3",
30
+ )
31
+ ],
32
+ final_answer={"summary": "Revenue by region", "format_type": "table", "content": "| region |"},
33
+ trace_id="trace-123",
34
+ reasoning=[{"node": "decomposer", "message": "split into 1 sub-query"}],
35
+ warnings=[{"node": "executor", "message": "row limit applied"}],
36
+ artifact_refs={"sq-1": _artifact_ref()},
37
+ )
38
+
39
+
40
+ def test_execute_query_returns_sql_and_final_answer(api_client):
41
+ client, _ = api_client(_query_result())
42
+
43
+ response = client.post("/api/v1/query", json={"natural_language": "revenue by region"})
44
+
45
+ assert response.status_code == 200
46
+ body = response.json()
47
+ assert body["trace_id"] == "trace-123"
48
+ assert body["final_answer"]["summary"] == "Revenue by region"
49
+ assert len(body["sub_queries"]) == 1
50
+ sub_query = body["sub_queries"][0]
51
+ assert sub_query["id"] == "sq-1"
52
+ assert sub_query["sql"] == "SELECT region, SUM(revenue) FROM sales GROUP BY region"
53
+ assert sub_query["datasource_id"] == "warehouse"
54
+ assert body["reasoning"][0]["node"] == "decomposer"
55
+ assert body["warnings"][0]["node"] == "executor"
56
+
57
+
58
+ def test_execute_query_returns_artifact_refs_not_rows(api_client):
59
+ client, _ = api_client(_query_result())
60
+
61
+ body = client.post("/api/v1/query", json={"natural_language": "revenue by region"}).json()
62
+
63
+ assert "results" not in body
64
+ assert body["artifact_refs"]["sq-1"]["uri"] == "file:///artifacts/sq-1.parquet"
65
+ assert body["artifact_refs"]["sq-1"]["row_count"] == 2
66
+
67
+
68
+ def test_execute_query_forwards_request_options(api_client):
69
+ client, engine = api_client(QueryResult())
70
+
71
+ client.post(
72
+ "/api/v1/query",
73
+ json={
74
+ "natural_language": "revenue by region",
75
+ "datasource_id": "warehouse",
76
+ "execute": False,
77
+ "user_context": {"user_id": "u-1"},
78
+ },
79
+ )
80
+
81
+ call = engine.calls[0]
82
+ assert call["natural_language"] == "revenue by region"
83
+ assert call["datasource_id"] == "warehouse"
84
+ assert call["execute"] is False
85
+ assert call["user_context"].user_id == "u-1"
86
+
87
+
88
+ def test_pipeline_errors_are_a_200_response(api_client):
89
+ client, _ = api_client(
90
+ QueryResult(
91
+ trace_id="trace-err",
92
+ errors=[{"node": "generator", "message": "no such column", "error_code": "SQL_GEN_FAILED", "severity": "ERROR"}],
93
+ )
94
+ )
95
+
96
+ response = client.post("/api/v1/query", json={"natural_language": "bad query"})
97
+
98
+ assert response.status_code == 200
99
+ body = response.json()
100
+ assert body["errors"][0]["error_code"] == "SQL_GEN_FAILED"
101
+ assert body["sub_queries"] == []
102
+
103
+
104
+ def test_unexpected_failure_is_a_500_without_internal_detail(api_client):
105
+ client, _ = api_client(RuntimeError("connection string user=admin password=hunter2"))
106
+
107
+ response = client.post("/api/v1/query", json={"natural_language": "revenue by region"})
108
+
109
+ assert response.status_code == 500
110
+ assert "hunter2" not in response.json()["detail"]
111
+
112
+
113
+ def test_invalid_payload_is_a_422(api_client):
114
+ client, _ = api_client(QueryResult())
115
+
116
+ assert client.post("/api/v1/query", json={}).status_code == 422
117
+
118
+
119
+ def test_query_route_is_not_a_coroutine():
120
+ """Blocking pipeline work must not run on the event loop."""
121
+ import inspect
122
+
123
+ from nl2sql_api.routes import query as query_routes
124
+
125
+ assert not inspect.iscoroutinefunction(query_routes.execute_query)
@@ -0,0 +1,55 @@
1
+ """Service-layer tests (ported from packages/api/test_services_updates.py)."""
2
+
3
+ from nl2sql_api.models.datasource import DatasourceRequest
4
+ from nl2sql_api.models.llm import LLMRequest
5
+ from nl2sql_api.services.datasource import DatasourceService
6
+ from nl2sql_api.services.llm import LLMService
7
+
8
+
9
+ class FakeEngine:
10
+ def __init__(self) -> None:
11
+ self._datasources = []
12
+ self._llms = {}
13
+
14
+ def add_datasource(self, config):
15
+ self._datasources.append(config["id"])
16
+
17
+ def list_datasources(self):
18
+ return list(self._datasources)
19
+
20
+ def configure_llm(self, config):
21
+ name = config.get("name", "default")
22
+ self._llms[name] = config
23
+
24
+ def list_llms(self):
25
+ return {
26
+ name: {"name": cfg.get("name", name)}
27
+ for name, cfg in self._llms.items()
28
+ }
29
+
30
+ def get_llm(self, name):
31
+ if name in self._llms:
32
+ return self._llms[name]
33
+ return self._llms.get("default")
34
+
35
+
36
+ def test_datasource_service_live_update():
37
+ engine = FakeEngine()
38
+ service = DatasourceService(engine)
39
+
40
+ service.add_datasource(DatasourceRequest(config={"id": "ds-1"}))
41
+
42
+ assert "ds-1" in service.list_datasources()
43
+
44
+
45
+ def test_llm_service_live_update():
46
+ engine = FakeEngine()
47
+ service = LLMService(engine)
48
+
49
+ service.configure_llm(
50
+ LLMRequest(config={"name": "custom", "provider": "openai", "model": "gpt"})
51
+ )
52
+
53
+ llms = service.list_llms()
54
+ assert "custom" in llms
55
+ assert service.get_llm("custom")["name"] == "custom"