smart-spatial-system 0.1.0__py3-none-any.whl
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.
- api/__init__.py +0 -0
- api/auth.py +48 -0
- api/main.py +115 -0
- api/routers/__init__.py +0 -0
- api/routers/data_source_connectors.py +448 -0
- api/routers/data_sources.py +84 -0
- api/routers/plugins_settings.py +151 -0
- api/routers/projects.py +81 -0
- api/routers/query_planner.py +204 -0
- api/routers/requests_outputs.py +229 -0
- api/routers/system.py +25 -0
- api/routers/uploads.py +150 -0
- api/routers/weights.py +97 -0
- api/support.py +77 -0
- config/__init__.py +13 -0
- config/plugins/__init__.py +7 -0
- config/plugins/area_perimeter_calc.example.yaml +26 -0
- config/plugins/area_perimeter_calc.yaml +16 -0
- config/plugins/attribute_statistics.example.yaml +30 -0
- config/plugins/attribute_statistics.yaml +25 -0
- config/plugins/band_math.example.yaml +30 -0
- config/plugins/band_math.yaml +13 -0
- config/plugins/buffer_analysis.example.yaml +18 -0
- config/plugins/buffer_analysis.yaml +18 -0
- config/plugins/centroid_extractor.example.yaml +20 -0
- config/plugins/centroid_extractor.yaml +10 -0
- config/plugins/core_vector.example.yaml +14 -0
- config/plugins/core_vector.yaml +14 -0
- config/plugins/crs_transformer.example.yaml +27 -0
- config/plugins/crs_transformer.yaml +12 -0
- config/plugins/data_writer_exporter.example.yaml +31 -0
- config/plugins/data_writer_exporter.yaml +24 -0
- config/plugins/dissolve_aggregator.example.yaml +37 -0
- config/plugins/dissolve_aggregator.yaml +23 -0
- config/plugins/distance_calculator.example.yaml +26 -0
- config/plugins/distance_calculator.yaml +17 -0
- config/plugins/feature_enrichment.example.yaml +3 -0
- config/plugins/feature_enrichment.yaml +3 -0
- config/plugins/feature_scoring.example.yaml +3 -0
- config/plugins/feature_scoring.yaml +3 -0
- config/plugins/geocoding_resolver.example.yaml +60 -0
- config/plugins/geocoding_resolver.yaml +32 -0
- config/plugins/geometry_validator.example.yaml +30 -0
- config/plugins/geometry_validator.yaml +24 -0
- config/plugins/local_raster_loader.example.yaml +13 -0
- config/plugins/local_raster_loader.yaml +13 -0
- config/plugins/local_vector_loader.example.yaml +14 -0
- config/plugins/local_vector_loader.yaml +13 -0
- config/plugins/ndvi_calculator.example.yaml +38 -0
- config/plugins/ndvi_calculator.yaml +17 -0
- config/plugins/nearest_neighbor.example.yaml +28 -0
- config/plugins/nearest_neighbor.yaml +20 -0
- config/plugins/postgis_connector.example.yaml +35 -0
- config/plugins/postgis_connector.yaml +27 -0
- config/plugins/raster_clip_mask.example.yaml +34 -0
- config/plugins/raster_clip_mask.yaml +14 -0
- config/plugins/raster_reclassify.example.yaml +49 -0
- config/plugins/raster_reclassify.yaml +16 -0
- config/plugins/raster_statistics.example.yaml +41 -0
- config/plugins/raster_statistics.yaml +33 -0
- config/plugins/raster_threshold.example.yaml +45 -0
- config/plugins/raster_threshold.yaml +22 -0
- config/plugins/raster_to_vector.example.yaml +56 -0
- config/plugins/raster_to_vector.yaml +28 -0
- config/plugins/risk_enrichment.example.yaml +6 -0
- config/plugins/risk_enrichment.yaml +6 -0
- config/plugins/slope_aspect.yaml +19 -0
- config/plugins/spatial_intersection.example.yaml +37 -0
- config/plugins/spatial_intersection.yaml +20 -0
- config/plugins/spatial_join.example.yaml +34 -0
- config/plugins/spatial_join.yaml +24 -0
- config/plugins/spatial_predicate.example.yaml +3 -0
- config/plugins/spatial_predicate.yaml +3 -0
- config/plugins/spatial_query_filter.example.yaml +23 -0
- config/plugins/spatial_query_filter.yaml +13 -0
- config/plugins/spectral_indices.example.yaml +46 -0
- config/plugins/spectral_indices.yaml +31 -0
- config/plugins/wms_wfs_fetcher.example.yaml +54 -0
- config/plugins/wms_wfs_fetcher.yaml +30 -0
- config/plugins/zonal_statistics.example.yaml +55 -0
- config/plugins/zonal_statistics.yaml +35 -0
- orchestrator/__init__.py +172 -0
- orchestrator/audit.py +335 -0
- orchestrator/capability_registry.py +311 -0
- orchestrator/capability_router.py +122 -0
- orchestrator/capability_scoring.py +592 -0
- orchestrator/data_source_service.py +11 -0
- orchestrator/error_contract.py +330 -0
- orchestrator/feedback.py +355 -0
- orchestrator/feedback_proposal_service.py +9 -0
- orchestrator/input_error_mapping.py +279 -0
- orchestrator/input_reference_resolver.py +446 -0
- orchestrator/kernel_artifacts.py +465 -0
- orchestrator/learning_signals.py +593 -0
- orchestrator/llm_client.py +173 -0
- orchestrator/llm_gate.py +379 -0
- orchestrator/llm_intent_planner.py +389 -0
- orchestrator/loader_plugin_contract.py +470 -0
- orchestrator/map_layer_service.py +11 -0
- orchestrator/map_layers.py +535 -0
- orchestrator/models.py +83 -0
- orchestrator/natural_query_runner.py +86 -0
- orchestrator/output_service.py +11 -0
- orchestrator/output_storage.py +418 -0
- orchestrator/pipeline_executor.py +83 -0
- orchestrator/plan_builder.py +97 -0
- orchestrator/planning/__init__.py +47 -0
- orchestrator/planning/capability_resolver.py +180 -0
- orchestrator/planning/dag.py +70 -0
- orchestrator/planning/dag_executor.py +503 -0
- orchestrator/planning/error_mapping.py +151 -0
- orchestrator/planning/kernel_execution_bridge.py +832 -0
- orchestrator/planning/kernel_plan_adapter.py +523 -0
- orchestrator/planning/llm_query_spec.py +457 -0
- orchestrator/planning/llm_spec_generator.py +1793 -0
- orchestrator/planning/op_catalog.py +1133 -0
- orchestrator/planning/output_parity.py +262 -0
- orchestrator/planning/planner.py +318 -0
- orchestrator/planning/postgis_semantic_resolver.py +746 -0
- orchestrator/planning/query_spec_contract.py +245 -0
- orchestrator/planning/report_spec.py +282 -0
- orchestrator/planning/runner.py +233 -0
- orchestrator/planning/semantic_planning_context.py +510 -0
- orchestrator/planning/spec.py +98 -0
- orchestrator/plugin_config_store.py +346 -0
- orchestrator/plugin_error_mapping.py +156 -0
- orchestrator/plugin_modules.py +50 -0
- orchestrator/plugin_runtime_service.py +9 -0
- orchestrator/plugin_state.py +140 -0
- orchestrator/production_response.py +885 -0
- orchestrator/project_service.py +13 -0
- orchestrator/project_store.py +328 -0
- orchestrator/provider_error_mapping.py +243 -0
- orchestrator/query_execution_service.py +11 -0
- orchestrator/query_parser.py +92 -0
- orchestrator/request_history_service.py +7 -0
- orchestrator/response_assembler.py +94 -0
- orchestrator/response_builder.py +130 -0
- orchestrator/router_decision.py +272 -0
- orchestrator/routing_aware_natural_query_runner.py +131 -0
- orchestrator/routing_aware_plan_builder.py +203 -0
- orchestrator/runtime_paths.py +102 -0
- orchestrator/service.py +1528 -0
- orchestrator/statistics.py +338 -0
- orchestrator/upload_service.py +11 -0
- orchestrator/upload_storage.py +508 -0
- orchestrator/weight_proposals.py +467 -0
- orchestrator/weight_store_persistence.py +295 -0
- orchestrator/weighted_router.py +370 -0
- plugins/__init__.py +0 -0
- plugins/_shared/__init__.py +0 -0
- plugins/_shared/local_path_validation.py +102 -0
- plugins/_shared/numeric_validation.py +39 -0
- plugins/_shared/plugin_config.py +337 -0
- plugins/area_perimeter_calc.py +723 -0
- plugins/attribute_statistics.py +737 -0
- plugins/band_math.py +589 -0
- plugins/buffer_analysis.py +744 -0
- plugins/centroid_extractor.py +904 -0
- plugins/core_vector.py +388 -0
- plugins/crs_transformer.py +765 -0
- plugins/data_writer_exporter.py +703 -0
- plugins/dissolve_aggregator.py +1247 -0
- plugins/distance_calculator.py +1137 -0
- plugins/feature_enrichment.py +553 -0
- plugins/feature_scoring.py +615 -0
- plugins/geocoding_resolver.py +1348 -0
- plugins/geometry_validator.py +875 -0
- plugins/local_raster_loader.py +365 -0
- plugins/local_vector_loader.py +547 -0
- plugins/ndvi_analysis.py +53 -0
- plugins/ndvi_calculator.py +563 -0
- plugins/nearest_neighbor.py +611 -0
- plugins/pdf_renderer.py +369 -0
- plugins/postgis_connector.py +1485 -0
- plugins/raster_clip_mask.py +954 -0
- plugins/raster_reclassify.py +743 -0
- plugins/raster_statistics.py +799 -0
- plugins/raster_threshold.py +773 -0
- plugins/raster_to_vector.py +993 -0
- plugins/real_estate_scoring.py +194 -0
- plugins/real_estate_spatial_enrichment.py +208 -0
- plugins/report_builder.py +613 -0
- plugins/risk_enrichment.py +471 -0
- plugins/slope_aspect.py +862 -0
- plugins/spatial_intersection.py +1099 -0
- plugins/spatial_join.py +802 -0
- plugins/spatial_predicate.py +453 -0
- plugins/spatial_query_filter.py +1060 -0
- plugins/spectral_indices.py +828 -0
- plugins/wms_wfs_fetcher.py +1012 -0
- plugins/zonal_statistics.py +928 -0
- smart_spatial_system/__init__.py +0 -0
- smart_spatial_system/__main__.py +13 -0
- smart_spatial_system/application/__init__.py +0 -0
- smart_spatial_system/application/services/__init__.py +0 -0
- smart_spatial_system/application/services/data_source_service.py +473 -0
- smart_spatial_system/application/services/feedback_proposal_service.py +239 -0
- smart_spatial_system/application/services/llm_intent_adapter.py +109 -0
- smart_spatial_system/application/services/map_layer_service.py +40 -0
- smart_spatial_system/application/services/output_service.py +44 -0
- smart_spatial_system/application/services/planning_execution_policy.py +162 -0
- smart_spatial_system/application/services/planning_response_adapter.py +212 -0
- smart_spatial_system/application/services/plugin_runtime_service.py +367 -0
- smart_spatial_system/application/services/project_service.py +167 -0
- smart_spatial_system/application/services/query_execution/__init__.py +0 -0
- smart_spatial_system/application/services/query_execution/direct_query_dispatch.py +84 -0
- smart_spatial_system/application/services/query_execution/natural_query_context.py +54 -0
- smart_spatial_system/application/services/query_execution/natural_query_dispatch.py +48 -0
- smart_spatial_system/application/services/query_execution/natural_query_execution.py +110 -0
- smart_spatial_system/application/services/query_execution/natural_query_failure.py +68 -0
- smart_spatial_system/application/services/query_execution/natural_query_persistence.py +69 -0
- smart_spatial_system/application/services/query_execution/planning_context.py +85 -0
- smart_spatial_system/application/services/query_execution/planning_execution.py +71 -0
- smart_spatial_system/application/services/query_execution/planning_persistence.py +100 -0
- smart_spatial_system/application/services/query_execution/planning_response.py +150 -0
- smart_spatial_system/application/services/query_execution/postgis_planning_context.py +514 -0
- smart_spatial_system/application/services/query_execution/real_estate_analysis_inspector.py +255 -0
- smart_spatial_system/application/services/query_execution/real_estate_classifier.py +151 -0
- smart_spatial_system/application/services/query_execution/real_estate_context.py +359 -0
- smart_spatial_system/application/services/query_execution/real_estate_document_renderer.py +165 -0
- smart_spatial_system/application/services/query_execution/real_estate_missing_inputs.py +132 -0
- smart_spatial_system/application/services/query_execution/real_estate_ranking_artifacts.py +99 -0
- smart_spatial_system/application/services/query_execution/real_estate_ranking_direct_handler.py +120 -0
- smart_spatial_system/application/services/query_execution/real_estate_ranking_execution.py +54 -0
- smart_spatial_system/application/services/query_execution/real_estate_ranking_query_spec.py +157 -0
- smart_spatial_system/application/services/query_execution/real_estate_ranking_response.py +231 -0
- smart_spatial_system/application/services/query_execution/real_estate_report_payload.py +108 -0
- smart_spatial_system/application/services/query_execution/real_estate_scoring.py +145 -0
- smart_spatial_system/application/services/query_execution_service.py +1408 -0
- smart_spatial_system/application/services/query_spec_enrichment.py +68 -0
- smart_spatial_system/application/services/real_estate_spatial_helpers.py +286 -0
- smart_spatial_system/application/services/request_history_service.py +82 -0
- smart_spatial_system/application/services/system_status_query_handler.py +243 -0
- smart_spatial_system/application/services/upload_service.py +56 -0
- smart_spatial_system/application/services/vector_display_handler.py +419 -0
- smart_spatial_system/application/services/vector_geojson_helpers.py +187 -0
- smart_spatial_system/application/services/vector_query_classifier.py +165 -0
- smart_spatial_system/cli.py +64 -0
- smart_spatial_system/domain/__init__.py +0 -0
- smart_spatial_system/domain/contracts/__init__.py +0 -0
- smart_spatial_system/domain/models/__init__.py +0 -0
- smart_spatial_system/infrastructure/__init__.py +0 -0
- smart_spatial_system/infrastructure/stores/__init__.py +0 -0
- smart_spatial_system/interfaces/__init__.py +0 -0
- smart_spatial_system/interfaces/api/__init__.py +0 -0
- smart_spatial_system/interfaces/api/routers/__init__.py +0 -0
- smart_spatial_system/plugins/__init__.py +0 -0
- smart_spatial_system/runtime/__init__.py +0 -0
- smart_spatial_system/shared/__init__.py +0 -0
- smart_spatial_system/workflows/__init__.py +0 -0
- smart_spatial_system/workflows/ndvi/__init__.py +0 -0
- smart_spatial_system/workflows/real_estate/__init__.py +0 -0
- smart_spatial_system-0.1.0.dist-info/METADATA +180 -0
- smart_spatial_system-0.1.0.dist-info/RECORD +262 -0
- smart_spatial_system-0.1.0.dist-info/WHEEL +5 -0
- smart_spatial_system-0.1.0.dist-info/entry_points.txt +2 -0
- smart_spatial_system-0.1.0.dist-info/licenses/LICENSE +21 -0
- smart_spatial_system-0.1.0.dist-info/top_level.txt +6 -0
- templates/__init__.py +9 -0
- templates/reports/__init__.py +6 -0
- templates/reports/real_estate_report.html +343 -0
api/__init__.py
ADDED
|
File without changes
|
api/auth.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
api.auth
|
|
3
|
+
|
|
4
|
+
Minimal shared-secret API key authentication.
|
|
5
|
+
|
|
6
|
+
This targets the "single team, self-hosted" deployment model documented in
|
|
7
|
+
CLAUDE.md: one deployer runs one backend instance for their own team, so a
|
|
8
|
+
single shared key is enough - there is no per-user login, session, or
|
|
9
|
+
multi-tenant model here.
|
|
10
|
+
|
|
11
|
+
Set SMART_SPATIAL_API_KEY to require every non-health request to send it
|
|
12
|
+
back as the X-API-Key header. Leaving it unset keeps the API open, matching
|
|
13
|
+
prior behavior, for local development and existing deployments that have
|
|
14
|
+
not opted in yet.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import secrets
|
|
20
|
+
|
|
21
|
+
from fastapi import HTTPException, Request, Security
|
|
22
|
+
from fastapi.security import APIKeyHeader
|
|
23
|
+
|
|
24
|
+
_API_KEY_HEADER_NAME = "X-API-Key"
|
|
25
|
+
|
|
26
|
+
_api_key_header = APIKeyHeader(name=_API_KEY_HEADER_NAME, auto_error=False)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def require_api_key(
|
|
30
|
+
request: Request,
|
|
31
|
+
provided_key: str | None = Security(_api_key_header),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""
|
|
34
|
+
FastAPI dependency: reject the request unless it carries the
|
|
35
|
+
X-API-Key header matching the server's configured key.
|
|
36
|
+
|
|
37
|
+
No-op (request allowed) when the server has no api_key configured.
|
|
38
|
+
"""
|
|
39
|
+
expected_key: str | None = getattr(request.app.state, "api_key", None)
|
|
40
|
+
|
|
41
|
+
if not expected_key:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
if not provided_key or not secrets.compare_digest(provided_key, expected_key):
|
|
45
|
+
raise HTTPException(
|
|
46
|
+
status_code=401,
|
|
47
|
+
detail=f"Missing or invalid {_API_KEY_HEADER_NAME} header.",
|
|
48
|
+
)
|
api/main.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI API for Smart Spatial System.
|
|
3
|
+
|
|
4
|
+
This module owns the HTTP application factory, CORS setup, service wiring,
|
|
5
|
+
and API router registration.
|
|
6
|
+
|
|
7
|
+
Run:
|
|
8
|
+
uvicorn api.main:app --reload
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
|
|
15
|
+
load_dotenv()
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
from fastapi import Depends, FastAPI
|
|
21
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
22
|
+
|
|
23
|
+
from api.auth import require_api_key
|
|
24
|
+
from api.routers.data_source_connectors import router as data_source_connectors_router
|
|
25
|
+
from api.routers.data_sources import router as data_sources_router
|
|
26
|
+
from api.routers.plugins_settings import router as plugins_settings_router
|
|
27
|
+
from api.routers.projects import router as projects_router
|
|
28
|
+
from api.routers.query_planner import router as query_planner_router
|
|
29
|
+
from api.routers.requests_outputs import router as requests_outputs_router
|
|
30
|
+
from api.routers.system import router as system_router
|
|
31
|
+
from api.routers.uploads import router as uploads_router
|
|
32
|
+
from api.routers.weights import router as weights_router
|
|
33
|
+
from orchestrator.service import OrchestratorService, OrchestratorServiceConfig
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class APIConfig:
|
|
38
|
+
"""
|
|
39
|
+
FastAPI application config.
|
|
40
|
+
|
|
41
|
+
For frontend development, default CORS allows localhost React/Vite ports.
|
|
42
|
+
In production, restrict allowed_origins.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
title: str = "Smart Spatial System API"
|
|
46
|
+
version: str = "0.1.0"
|
|
47
|
+
description: str = "MVP API for natural geospatial query execution."
|
|
48
|
+
|
|
49
|
+
allow_origins: tuple[str, ...] = (
|
|
50
|
+
"http://localhost:3000",
|
|
51
|
+
"http://127.0.0.1:3000",
|
|
52
|
+
"http://localhost:5173",
|
|
53
|
+
"http://127.0.0.1:5173",
|
|
54
|
+
)
|
|
55
|
+
allow_credentials: bool = True
|
|
56
|
+
allow_methods: tuple[str, ...] = ("*",)
|
|
57
|
+
allow_headers: tuple[str, ...] = ("*",)
|
|
58
|
+
|
|
59
|
+
# When set, every route except "/" and "/health" requires the matching
|
|
60
|
+
# X-API-Key header (see api/auth.py). Defaults to the SMART_SPATIAL_API_KEY
|
|
61
|
+
# env var; leave both unset to keep the API open (local dev, or a
|
|
62
|
+
# deployment that has not opted in yet).
|
|
63
|
+
api_key: str | None = field(default_factory=lambda: os.environ.get("SMART_SPATIAL_API_KEY") or None)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def create_app(
|
|
67
|
+
*,
|
|
68
|
+
service: OrchestratorService | None = None,
|
|
69
|
+
service_config: OrchestratorServiceConfig | None = None,
|
|
70
|
+
api_config: APIConfig | None = None,
|
|
71
|
+
) -> FastAPI:
|
|
72
|
+
"""
|
|
73
|
+
Create FastAPI app.
|
|
74
|
+
|
|
75
|
+
Tests can inject a service with tmp_path weights.
|
|
76
|
+
Production/dev can use default config.
|
|
77
|
+
"""
|
|
78
|
+
final_api_config = api_config or APIConfig()
|
|
79
|
+
|
|
80
|
+
app = FastAPI(
|
|
81
|
+
title=final_api_config.title,
|
|
82
|
+
version=final_api_config.version,
|
|
83
|
+
description=final_api_config.description,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
app.add_middleware(
|
|
87
|
+
CORSMiddleware,
|
|
88
|
+
allow_origins=list(final_api_config.allow_origins),
|
|
89
|
+
allow_credentials=final_api_config.allow_credentials,
|
|
90
|
+
allow_methods=list(final_api_config.allow_methods),
|
|
91
|
+
allow_headers=list(final_api_config.allow_headers),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
app.state.service = service or OrchestratorService(
|
|
95
|
+
service_config or OrchestratorServiceConfig()
|
|
96
|
+
)
|
|
97
|
+
app.state.api_key = final_api_config.api_key
|
|
98
|
+
|
|
99
|
+
protected = [Depends(require_api_key)]
|
|
100
|
+
|
|
101
|
+
# "/" and "/health" stay open for monitoring/load-balancer liveness checks.
|
|
102
|
+
app.include_router(system_router)
|
|
103
|
+
app.include_router(projects_router, dependencies=protected)
|
|
104
|
+
app.include_router(uploads_router, dependencies=protected)
|
|
105
|
+
app.include_router(data_sources_router, dependencies=protected)
|
|
106
|
+
app.include_router(data_source_connectors_router, dependencies=protected)
|
|
107
|
+
app.include_router(plugins_settings_router, dependencies=protected)
|
|
108
|
+
app.include_router(requests_outputs_router, dependencies=protected)
|
|
109
|
+
app.include_router(weights_router, dependencies=protected)
|
|
110
|
+
app.include_router(query_planner_router, dependencies=protected)
|
|
111
|
+
|
|
112
|
+
return app
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
app = create_app()
|
api/routers/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import (
|
|
6
|
+
APIRouter,
|
|
7
|
+
Body,
|
|
8
|
+
HTTPException,
|
|
9
|
+
Request,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from api.support import (
|
|
13
|
+
http_error_detail as _http_error_detail,
|
|
14
|
+
)
|
|
15
|
+
from api.support import (
|
|
16
|
+
json_safe as _json_safe,
|
|
17
|
+
)
|
|
18
|
+
from api.support import (
|
|
19
|
+
service as _service,
|
|
20
|
+
)
|
|
21
|
+
from orchestrator.service import OrchestratorServiceError
|
|
22
|
+
|
|
23
|
+
router = APIRouter()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_service_capability(
|
|
27
|
+
svc: Any,
|
|
28
|
+
candidate_names: tuple[str, ...],
|
|
29
|
+
) -> Any:
|
|
30
|
+
"""
|
|
31
|
+
Resolve a callable capability through the service registry.
|
|
32
|
+
|
|
33
|
+
API routers must not import concrete plugin implementation modules.
|
|
34
|
+
They should go through the service/registry/capability boundary.
|
|
35
|
+
"""
|
|
36
|
+
registry = getattr(svc, "registry", None)
|
|
37
|
+
if registry is None:
|
|
38
|
+
raise HTTPException(
|
|
39
|
+
status_code=400,
|
|
40
|
+
detail="Capability registry is not available.",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
last_error: Exception | None = None
|
|
44
|
+
|
|
45
|
+
for capability_name in candidate_names:
|
|
46
|
+
try:
|
|
47
|
+
assert_enabled = getattr(svc, "_assert_capability_enabled", None)
|
|
48
|
+
if callable(assert_enabled):
|
|
49
|
+
assert_enabled(capability_name)
|
|
50
|
+
|
|
51
|
+
binding = registry.resolve(capability_name)
|
|
52
|
+
capability = getattr(binding, "callable", binding)
|
|
53
|
+
if callable(capability):
|
|
54
|
+
return capability
|
|
55
|
+
|
|
56
|
+
except Exception as exc:
|
|
57
|
+
last_error = exc
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
detail = "Capability is not available: " + ", ".join(candidate_names)
|
|
61
|
+
if last_error is not None:
|
|
62
|
+
detail = f"{detail}. Last error: {last_error}"
|
|
63
|
+
|
|
64
|
+
raise HTTPException(
|
|
65
|
+
status_code=400,
|
|
66
|
+
detail=detail,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.post("/data-sources/csv-table")
|
|
71
|
+
def register_csv_table_source(
|
|
72
|
+
request: Request,
|
|
73
|
+
payload: dict[str, Any] = Body(...),
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
svc = _service(request)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
return _json_safe(svc.register_csv_table_source(payload))
|
|
79
|
+
except OrchestratorServiceError as exc:
|
|
80
|
+
raise HTTPException(
|
|
81
|
+
status_code=400,
|
|
82
|
+
detail=_http_error_detail(exc),
|
|
83
|
+
) from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@router.post("/data-sources/wms")
|
|
87
|
+
def register_wms_source(
|
|
88
|
+
request: Request,
|
|
89
|
+
payload: dict[str, Any] = Body(...),
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
svc = _service(request)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
return _json_safe(svc.register_wms_source(payload))
|
|
95
|
+
except OrchestratorServiceError as exc:
|
|
96
|
+
raise HTTPException(
|
|
97
|
+
status_code=400,
|
|
98
|
+
detail=_http_error_detail(exc),
|
|
99
|
+
) from exc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@router.post("/data-sources/postgis")
|
|
103
|
+
def register_postgis_source(
|
|
104
|
+
request: Request,
|
|
105
|
+
payload: dict[str, Any] = Body(...),
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""
|
|
108
|
+
Connect to PostGIS, fetch a spatial table and register as a data source.
|
|
109
|
+
|
|
110
|
+
Expected body:
|
|
111
|
+
{
|
|
112
|
+
"project_id": "optional",
|
|
113
|
+
"display_name": "optional",
|
|
114
|
+
"table": "roads",
|
|
115
|
+
"schema": "public",
|
|
116
|
+
"geom_col": "geom",
|
|
117
|
+
"where": "optional SQL filter",
|
|
118
|
+
"limit": 1000,
|
|
119
|
+
"output_srid": 4326,
|
|
120
|
+
"dsn": "postgresql://user:pass@host/db",
|
|
121
|
+
"host": "localhost",
|
|
122
|
+
"port": 5432,
|
|
123
|
+
"database": "gis",
|
|
124
|
+
"user": "postgres",
|
|
125
|
+
"password": "secret",
|
|
126
|
+
"profile": "optional config profile"
|
|
127
|
+
}
|
|
128
|
+
"""
|
|
129
|
+
svc = _service(request)
|
|
130
|
+
|
|
131
|
+
table = payload.get("table")
|
|
132
|
+
if not isinstance(table, str) or not table.strip():
|
|
133
|
+
raise HTTPException(
|
|
134
|
+
status_code=400,
|
|
135
|
+
detail="'table' must be a non-empty string.",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
fetch_postgis_layer = _resolve_service_capability(
|
|
140
|
+
svc,
|
|
141
|
+
(
|
|
142
|
+
"fetch_postgis_layer",
|
|
143
|
+
"query_database_postgis",
|
|
144
|
+
"load_postgis_layer",
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
result = fetch_postgis_layer(
|
|
149
|
+
table=str(table).strip(),
|
|
150
|
+
profile=payload.get("profile"),
|
|
151
|
+
dsn=payload.get("dsn"),
|
|
152
|
+
schema=payload.get("schema"),
|
|
153
|
+
geom_col=payload.get("geom_col"),
|
|
154
|
+
where=payload.get("where"),
|
|
155
|
+
limit=payload.get("limit"),
|
|
156
|
+
output_srid=payload.get("output_srid"),
|
|
157
|
+
host=payload.get("host"),
|
|
158
|
+
port=payload.get("port"),
|
|
159
|
+
database=payload.get("database"),
|
|
160
|
+
user=payload.get("user"),
|
|
161
|
+
password=payload.get("password"),
|
|
162
|
+
connect_timeout=payload.get("connect_timeout"),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
except HTTPException:
|
|
166
|
+
raise
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
raise HTTPException(
|
|
169
|
+
status_code=400,
|
|
170
|
+
detail=f"PostGIS fetch failed: {exc}",
|
|
171
|
+
) from exc
|
|
172
|
+
|
|
173
|
+
import json as _json
|
|
174
|
+
|
|
175
|
+
features = getattr(result, "features", None) or []
|
|
176
|
+
metadata = getattr(result, "metadata", None) or {}
|
|
177
|
+
|
|
178
|
+
geojson = {
|
|
179
|
+
"type": "FeatureCollection",
|
|
180
|
+
"features": features,
|
|
181
|
+
"metadata": metadata,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
display_name = (
|
|
185
|
+
payload.get("display_name")
|
|
186
|
+
or f"{payload.get('schema', 'public')}.{table}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
content = _json.dumps(geojson, ensure_ascii=False).encode("utf-8")
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
upload = svc.save_upload(
|
|
193
|
+
filename=f"{table}.geojson",
|
|
194
|
+
content=content,
|
|
195
|
+
content_type="application/geo+json",
|
|
196
|
+
kind="vector",
|
|
197
|
+
user_context={
|
|
198
|
+
"source": "postgis",
|
|
199
|
+
"source_type": "postgis",
|
|
200
|
+
"display_name": display_name,
|
|
201
|
+
"table": table,
|
|
202
|
+
"schema": payload.get("schema", "public"),
|
|
203
|
+
"postgis_metadata": metadata,
|
|
204
|
+
},
|
|
205
|
+
project_id=payload.get("project_id"),
|
|
206
|
+
)
|
|
207
|
+
except OrchestratorServiceError as exc:
|
|
208
|
+
raise HTTPException(
|
|
209
|
+
status_code=400,
|
|
210
|
+
detail=_http_error_detail(exc),
|
|
211
|
+
) from exc
|
|
212
|
+
|
|
213
|
+
return _json_safe({
|
|
214
|
+
**upload,
|
|
215
|
+
"source_type": "postgis",
|
|
216
|
+
"feature_count": len(features),
|
|
217
|
+
"postgis_metadata": metadata,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@router.post("/data-sources/wfs")
|
|
222
|
+
def register_wfs_source(
|
|
223
|
+
request: Request,
|
|
224
|
+
payload: dict[str, Any] = Body(...),
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
"""
|
|
227
|
+
Fetch features from WFS service and register as a data source.
|
|
228
|
+
|
|
229
|
+
Expected body:
|
|
230
|
+
{
|
|
231
|
+
"project_id": "optional",
|
|
232
|
+
"display_name": "optional",
|
|
233
|
+
"base_url": "https://...",
|
|
234
|
+
"type_name": "layer:name",
|
|
235
|
+
"layer": "alias from config",
|
|
236
|
+
"service": "config profile name",
|
|
237
|
+
"version": "2.0.0",
|
|
238
|
+
"output_format": "application/json",
|
|
239
|
+
"srs_name": "EPSG:4326",
|
|
240
|
+
"bbox": [minx, miny, maxx, maxy],
|
|
241
|
+
"max_features": 1000,
|
|
242
|
+
"timeout": 30
|
|
243
|
+
}
|
|
244
|
+
"""
|
|
245
|
+
svc = _service(request)
|
|
246
|
+
|
|
247
|
+
base_url = payload.get("base_url")
|
|
248
|
+
type_name = payload.get("type_name") or payload.get("layer")
|
|
249
|
+
|
|
250
|
+
if not payload.get("service") and (
|
|
251
|
+
not isinstance(base_url, str) or not base_url.strip()
|
|
252
|
+
):
|
|
253
|
+
raise HTTPException(
|
|
254
|
+
status_code=400,
|
|
255
|
+
detail="'base_url' or 'service' must be provided.",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if not payload.get("service") and (
|
|
259
|
+
not isinstance(type_name, str) or not str(type_name).strip()
|
|
260
|
+
):
|
|
261
|
+
raise HTTPException(
|
|
262
|
+
status_code=400,
|
|
263
|
+
detail="'type_name' or 'layer' must be provided.",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
fetch_wfs_features = _resolve_service_capability(
|
|
268
|
+
svc,
|
|
269
|
+
(
|
|
270
|
+
"fetch_wfs_features",
|
|
271
|
+
"load_wfs_features",
|
|
272
|
+
"load_wfs_layer",
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
result = fetch_wfs_features(
|
|
277
|
+
service=payload.get("service"),
|
|
278
|
+
base_url=base_url,
|
|
279
|
+
type_name=payload.get("type_name"),
|
|
280
|
+
layer=payload.get("layer"),
|
|
281
|
+
version=payload.get("version"),
|
|
282
|
+
output_format=payload.get("output_format"),
|
|
283
|
+
srs_name=payload.get("srs_name"),
|
|
284
|
+
bbox=payload.get("bbox"),
|
|
285
|
+
max_features=payload.get("max_features"),
|
|
286
|
+
property_name=payload.get("property_name"),
|
|
287
|
+
timeout=payload.get("timeout"),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
except HTTPException:
|
|
291
|
+
raise
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
raise HTTPException(
|
|
294
|
+
status_code=400,
|
|
295
|
+
detail=f"WFS fetch failed: {exc}",
|
|
296
|
+
) from exc
|
|
297
|
+
|
|
298
|
+
import json as _json
|
|
299
|
+
|
|
300
|
+
features = getattr(result, "features", None) or []
|
|
301
|
+
metadata = getattr(result, "metadata", None) or {}
|
|
302
|
+
|
|
303
|
+
geojson = {
|
|
304
|
+
"type": "FeatureCollection",
|
|
305
|
+
"features": features,
|
|
306
|
+
"metadata": metadata,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
display_name = (
|
|
310
|
+
payload.get("display_name")
|
|
311
|
+
or payload.get("type_name")
|
|
312
|
+
or payload.get("layer")
|
|
313
|
+
or "wfs_layer"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
safe_name = str(display_name).replace(":", "_").replace("/", "_")
|
|
317
|
+
content = _json.dumps(geojson, ensure_ascii=False).encode("utf-8")
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
upload = svc.save_upload(
|
|
321
|
+
filename=f"{safe_name}.geojson",
|
|
322
|
+
content=content,
|
|
323
|
+
content_type="application/geo+json",
|
|
324
|
+
kind="vector",
|
|
325
|
+
user_context={
|
|
326
|
+
"source": "wfs",
|
|
327
|
+
"source_type": "wfs",
|
|
328
|
+
"display_name": display_name,
|
|
329
|
+
"base_url": base_url,
|
|
330
|
+
"type_name": type_name,
|
|
331
|
+
"wfs_metadata": metadata,
|
|
332
|
+
},
|
|
333
|
+
project_id=payload.get("project_id"),
|
|
334
|
+
)
|
|
335
|
+
except OrchestratorServiceError as exc:
|
|
336
|
+
raise HTTPException(
|
|
337
|
+
status_code=400,
|
|
338
|
+
detail=_http_error_detail(exc),
|
|
339
|
+
) from exc
|
|
340
|
+
|
|
341
|
+
return _json_safe({
|
|
342
|
+
**upload,
|
|
343
|
+
"source_type": "wfs",
|
|
344
|
+
"feature_count": len(features),
|
|
345
|
+
"wfs_metadata": metadata,
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@router.post("/data-sources/url")
|
|
350
|
+
def register_url_source(
|
|
351
|
+
request: Request,
|
|
352
|
+
payload: dict[str, Any] = Body(...),
|
|
353
|
+
) -> dict[str, Any]:
|
|
354
|
+
"""
|
|
355
|
+
Fetch GeoJSON from a URL and register as a data source.
|
|
356
|
+
|
|
357
|
+
Expected body:
|
|
358
|
+
{
|
|
359
|
+
"project_id": "optional",
|
|
360
|
+
"display_name": "optional",
|
|
361
|
+
"url": "https://example.com/data.geojson",
|
|
362
|
+
"kind": "vector",
|
|
363
|
+
"timeout": 30,
|
|
364
|
+
"headers": {"Authorization": "Bearer ..."}
|
|
365
|
+
}
|
|
366
|
+
"""
|
|
367
|
+
svc = _service(request)
|
|
368
|
+
|
|
369
|
+
url = payload.get("url")
|
|
370
|
+
if not isinstance(url, str) or not url.strip():
|
|
371
|
+
raise HTTPException(
|
|
372
|
+
status_code=400,
|
|
373
|
+
detail="'url' must be a non-empty string.",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
url = url.strip()
|
|
377
|
+
timeout = int(payload.get("timeout") or 30)
|
|
378
|
+
kind = str(payload.get("kind") or "vector")
|
|
379
|
+
extra_headers = payload.get("headers") or {}
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
import httpx
|
|
383
|
+
response = httpx.get(
|
|
384
|
+
url,
|
|
385
|
+
timeout=timeout,
|
|
386
|
+
headers=extra_headers,
|
|
387
|
+
follow_redirects=True,
|
|
388
|
+
)
|
|
389
|
+
response.raise_for_status()
|
|
390
|
+
content = response.content
|
|
391
|
+
content_type = response.headers.get("content-type", "application/json")
|
|
392
|
+
except Exception as exc:
|
|
393
|
+
raise HTTPException(
|
|
394
|
+
status_code=400,
|
|
395
|
+
detail=f"URL fetch failed: {exc}",
|
|
396
|
+
) from exc
|
|
397
|
+
|
|
398
|
+
import json as _json
|
|
399
|
+
from urllib.parse import urlparse as _urlparse
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
parsed_json = _json.loads(content)
|
|
403
|
+
if not isinstance(parsed_json, dict):
|
|
404
|
+
raise ValueError("Response is not a JSON object.")
|
|
405
|
+
except Exception as exc:
|
|
406
|
+
raise HTTPException(
|
|
407
|
+
status_code=422,
|
|
408
|
+
detail=f"Response is not valid JSON: {exc}",
|
|
409
|
+
) from exc
|
|
410
|
+
|
|
411
|
+
url_path = _urlparse(url).path
|
|
412
|
+
raw_filename = url_path.split("/")[-1] or "remote_data.geojson"
|
|
413
|
+
display_name = payload.get("display_name") or raw_filename
|
|
414
|
+
|
|
415
|
+
feature_count = 0
|
|
416
|
+
if parsed_json.get("type") == "FeatureCollection":
|
|
417
|
+
feature_count = len(parsed_json.get("features") or [])
|
|
418
|
+
elif parsed_json.get("type") == "Feature":
|
|
419
|
+
feature_count = 1
|
|
420
|
+
|
|
421
|
+
try:
|
|
422
|
+
upload = svc.save_upload(
|
|
423
|
+
filename=raw_filename,
|
|
424
|
+
content=content,
|
|
425
|
+
content_type=content_type,
|
|
426
|
+
kind=kind,
|
|
427
|
+
user_context={
|
|
428
|
+
"source": "url",
|
|
429
|
+
"source_type": "url",
|
|
430
|
+
"display_name": display_name,
|
|
431
|
+
"original_url": url,
|
|
432
|
+
"feature_count": feature_count,
|
|
433
|
+
},
|
|
434
|
+
project_id=payload.get("project_id"),
|
|
435
|
+
)
|
|
436
|
+
except OrchestratorServiceError as exc:
|
|
437
|
+
raise HTTPException(
|
|
438
|
+
status_code=400,
|
|
439
|
+
detail=_http_error_detail(exc),
|
|
440
|
+
) from exc
|
|
441
|
+
|
|
442
|
+
return _json_safe({
|
|
443
|
+
**upload,
|
|
444
|
+
"source_type": "url",
|
|
445
|
+
"feature_count": feature_count,
|
|
446
|
+
"original_url": url,
|
|
447
|
+
})
|
|
448
|
+
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Body, HTTPException, Request
|
|
6
|
+
|
|
7
|
+
from api.support import (
|
|
8
|
+
http_error_detail as _http_error_detail,
|
|
9
|
+
)
|
|
10
|
+
from api.support import (
|
|
11
|
+
json_safe as _json_safe,
|
|
12
|
+
)
|
|
13
|
+
from api.support import (
|
|
14
|
+
service as _service,
|
|
15
|
+
)
|
|
16
|
+
from orchestrator.service import OrchestratorServiceError
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("/data-sources/{upload_id}")
|
|
22
|
+
def get_data_source(
|
|
23
|
+
request: Request,
|
|
24
|
+
upload_id: str,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
svc = _service(request)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
return _json_safe(svc.get_data_source(upload_id))
|
|
30
|
+
except OrchestratorServiceError as exc:
|
|
31
|
+
raise HTTPException(
|
|
32
|
+
status_code=404,
|
|
33
|
+
detail=_http_error_detail(exc),
|
|
34
|
+
) from exc
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.delete("/data-sources/{upload_id}")
|
|
38
|
+
def delete_data_source(
|
|
39
|
+
request: Request,
|
|
40
|
+
upload_id: str,
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
svc = _service(request)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
return _json_safe(svc.delete_data_source(upload_id))
|
|
46
|
+
except OrchestratorServiceError as exc:
|
|
47
|
+
raise HTTPException(
|
|
48
|
+
status_code=400,
|
|
49
|
+
detail=_http_error_detail(exc),
|
|
50
|
+
) from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.patch("/data-sources/{upload_id}")
|
|
54
|
+
def update_data_source(
|
|
55
|
+
request: Request,
|
|
56
|
+
upload_id: str,
|
|
57
|
+
payload: dict[str, Any] = Body(...),
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
svc = _service(request)
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
return _json_safe(svc.update_data_source(upload_id, payload))
|
|
63
|
+
except OrchestratorServiceError as exc:
|
|
64
|
+
raise HTTPException(
|
|
65
|
+
status_code=400,
|
|
66
|
+
detail=_http_error_detail(exc),
|
|
67
|
+
) from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.get("/data-sources/{upload_id}/preview")
|
|
71
|
+
def preview_data_source(
|
|
72
|
+
request: Request,
|
|
73
|
+
upload_id: str,
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
svc = _service(request)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
return _json_safe(svc.preview_data_source(upload_id))
|
|
79
|
+
except OrchestratorServiceError as exc:
|
|
80
|
+
raise HTTPException(
|
|
81
|
+
status_code=404,
|
|
82
|
+
detail=_http_error_detail(exc),
|
|
83
|
+
) from exc
|
|
84
|
+
|