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.
Files changed (262) hide show
  1. api/__init__.py +0 -0
  2. api/auth.py +48 -0
  3. api/main.py +115 -0
  4. api/routers/__init__.py +0 -0
  5. api/routers/data_source_connectors.py +448 -0
  6. api/routers/data_sources.py +84 -0
  7. api/routers/plugins_settings.py +151 -0
  8. api/routers/projects.py +81 -0
  9. api/routers/query_planner.py +204 -0
  10. api/routers/requests_outputs.py +229 -0
  11. api/routers/system.py +25 -0
  12. api/routers/uploads.py +150 -0
  13. api/routers/weights.py +97 -0
  14. api/support.py +77 -0
  15. config/__init__.py +13 -0
  16. config/plugins/__init__.py +7 -0
  17. config/plugins/area_perimeter_calc.example.yaml +26 -0
  18. config/plugins/area_perimeter_calc.yaml +16 -0
  19. config/plugins/attribute_statistics.example.yaml +30 -0
  20. config/plugins/attribute_statistics.yaml +25 -0
  21. config/plugins/band_math.example.yaml +30 -0
  22. config/plugins/band_math.yaml +13 -0
  23. config/plugins/buffer_analysis.example.yaml +18 -0
  24. config/plugins/buffer_analysis.yaml +18 -0
  25. config/plugins/centroid_extractor.example.yaml +20 -0
  26. config/plugins/centroid_extractor.yaml +10 -0
  27. config/plugins/core_vector.example.yaml +14 -0
  28. config/plugins/core_vector.yaml +14 -0
  29. config/plugins/crs_transformer.example.yaml +27 -0
  30. config/plugins/crs_transformer.yaml +12 -0
  31. config/plugins/data_writer_exporter.example.yaml +31 -0
  32. config/plugins/data_writer_exporter.yaml +24 -0
  33. config/plugins/dissolve_aggregator.example.yaml +37 -0
  34. config/plugins/dissolve_aggregator.yaml +23 -0
  35. config/plugins/distance_calculator.example.yaml +26 -0
  36. config/plugins/distance_calculator.yaml +17 -0
  37. config/plugins/feature_enrichment.example.yaml +3 -0
  38. config/plugins/feature_enrichment.yaml +3 -0
  39. config/plugins/feature_scoring.example.yaml +3 -0
  40. config/plugins/feature_scoring.yaml +3 -0
  41. config/plugins/geocoding_resolver.example.yaml +60 -0
  42. config/plugins/geocoding_resolver.yaml +32 -0
  43. config/plugins/geometry_validator.example.yaml +30 -0
  44. config/plugins/geometry_validator.yaml +24 -0
  45. config/plugins/local_raster_loader.example.yaml +13 -0
  46. config/plugins/local_raster_loader.yaml +13 -0
  47. config/plugins/local_vector_loader.example.yaml +14 -0
  48. config/plugins/local_vector_loader.yaml +13 -0
  49. config/plugins/ndvi_calculator.example.yaml +38 -0
  50. config/plugins/ndvi_calculator.yaml +17 -0
  51. config/plugins/nearest_neighbor.example.yaml +28 -0
  52. config/plugins/nearest_neighbor.yaml +20 -0
  53. config/plugins/postgis_connector.example.yaml +35 -0
  54. config/plugins/postgis_connector.yaml +27 -0
  55. config/plugins/raster_clip_mask.example.yaml +34 -0
  56. config/plugins/raster_clip_mask.yaml +14 -0
  57. config/plugins/raster_reclassify.example.yaml +49 -0
  58. config/plugins/raster_reclassify.yaml +16 -0
  59. config/plugins/raster_statistics.example.yaml +41 -0
  60. config/plugins/raster_statistics.yaml +33 -0
  61. config/plugins/raster_threshold.example.yaml +45 -0
  62. config/plugins/raster_threshold.yaml +22 -0
  63. config/plugins/raster_to_vector.example.yaml +56 -0
  64. config/plugins/raster_to_vector.yaml +28 -0
  65. config/plugins/risk_enrichment.example.yaml +6 -0
  66. config/plugins/risk_enrichment.yaml +6 -0
  67. config/plugins/slope_aspect.yaml +19 -0
  68. config/plugins/spatial_intersection.example.yaml +37 -0
  69. config/plugins/spatial_intersection.yaml +20 -0
  70. config/plugins/spatial_join.example.yaml +34 -0
  71. config/plugins/spatial_join.yaml +24 -0
  72. config/plugins/spatial_predicate.example.yaml +3 -0
  73. config/plugins/spatial_predicate.yaml +3 -0
  74. config/plugins/spatial_query_filter.example.yaml +23 -0
  75. config/plugins/spatial_query_filter.yaml +13 -0
  76. config/plugins/spectral_indices.example.yaml +46 -0
  77. config/plugins/spectral_indices.yaml +31 -0
  78. config/plugins/wms_wfs_fetcher.example.yaml +54 -0
  79. config/plugins/wms_wfs_fetcher.yaml +30 -0
  80. config/plugins/zonal_statistics.example.yaml +55 -0
  81. config/plugins/zonal_statistics.yaml +35 -0
  82. orchestrator/__init__.py +172 -0
  83. orchestrator/audit.py +335 -0
  84. orchestrator/capability_registry.py +311 -0
  85. orchestrator/capability_router.py +122 -0
  86. orchestrator/capability_scoring.py +592 -0
  87. orchestrator/data_source_service.py +11 -0
  88. orchestrator/error_contract.py +330 -0
  89. orchestrator/feedback.py +355 -0
  90. orchestrator/feedback_proposal_service.py +9 -0
  91. orchestrator/input_error_mapping.py +279 -0
  92. orchestrator/input_reference_resolver.py +446 -0
  93. orchestrator/kernel_artifacts.py +465 -0
  94. orchestrator/learning_signals.py +593 -0
  95. orchestrator/llm_client.py +173 -0
  96. orchestrator/llm_gate.py +379 -0
  97. orchestrator/llm_intent_planner.py +389 -0
  98. orchestrator/loader_plugin_contract.py +470 -0
  99. orchestrator/map_layer_service.py +11 -0
  100. orchestrator/map_layers.py +535 -0
  101. orchestrator/models.py +83 -0
  102. orchestrator/natural_query_runner.py +86 -0
  103. orchestrator/output_service.py +11 -0
  104. orchestrator/output_storage.py +418 -0
  105. orchestrator/pipeline_executor.py +83 -0
  106. orchestrator/plan_builder.py +97 -0
  107. orchestrator/planning/__init__.py +47 -0
  108. orchestrator/planning/capability_resolver.py +180 -0
  109. orchestrator/planning/dag.py +70 -0
  110. orchestrator/planning/dag_executor.py +503 -0
  111. orchestrator/planning/error_mapping.py +151 -0
  112. orchestrator/planning/kernel_execution_bridge.py +832 -0
  113. orchestrator/planning/kernel_plan_adapter.py +523 -0
  114. orchestrator/planning/llm_query_spec.py +457 -0
  115. orchestrator/planning/llm_spec_generator.py +1793 -0
  116. orchestrator/planning/op_catalog.py +1133 -0
  117. orchestrator/planning/output_parity.py +262 -0
  118. orchestrator/planning/planner.py +318 -0
  119. orchestrator/planning/postgis_semantic_resolver.py +746 -0
  120. orchestrator/planning/query_spec_contract.py +245 -0
  121. orchestrator/planning/report_spec.py +282 -0
  122. orchestrator/planning/runner.py +233 -0
  123. orchestrator/planning/semantic_planning_context.py +510 -0
  124. orchestrator/planning/spec.py +98 -0
  125. orchestrator/plugin_config_store.py +346 -0
  126. orchestrator/plugin_error_mapping.py +156 -0
  127. orchestrator/plugin_modules.py +50 -0
  128. orchestrator/plugin_runtime_service.py +9 -0
  129. orchestrator/plugin_state.py +140 -0
  130. orchestrator/production_response.py +885 -0
  131. orchestrator/project_service.py +13 -0
  132. orchestrator/project_store.py +328 -0
  133. orchestrator/provider_error_mapping.py +243 -0
  134. orchestrator/query_execution_service.py +11 -0
  135. orchestrator/query_parser.py +92 -0
  136. orchestrator/request_history_service.py +7 -0
  137. orchestrator/response_assembler.py +94 -0
  138. orchestrator/response_builder.py +130 -0
  139. orchestrator/router_decision.py +272 -0
  140. orchestrator/routing_aware_natural_query_runner.py +131 -0
  141. orchestrator/routing_aware_plan_builder.py +203 -0
  142. orchestrator/runtime_paths.py +102 -0
  143. orchestrator/service.py +1528 -0
  144. orchestrator/statistics.py +338 -0
  145. orchestrator/upload_service.py +11 -0
  146. orchestrator/upload_storage.py +508 -0
  147. orchestrator/weight_proposals.py +467 -0
  148. orchestrator/weight_store_persistence.py +295 -0
  149. orchestrator/weighted_router.py +370 -0
  150. plugins/__init__.py +0 -0
  151. plugins/_shared/__init__.py +0 -0
  152. plugins/_shared/local_path_validation.py +102 -0
  153. plugins/_shared/numeric_validation.py +39 -0
  154. plugins/_shared/plugin_config.py +337 -0
  155. plugins/area_perimeter_calc.py +723 -0
  156. plugins/attribute_statistics.py +737 -0
  157. plugins/band_math.py +589 -0
  158. plugins/buffer_analysis.py +744 -0
  159. plugins/centroid_extractor.py +904 -0
  160. plugins/core_vector.py +388 -0
  161. plugins/crs_transformer.py +765 -0
  162. plugins/data_writer_exporter.py +703 -0
  163. plugins/dissolve_aggregator.py +1247 -0
  164. plugins/distance_calculator.py +1137 -0
  165. plugins/feature_enrichment.py +553 -0
  166. plugins/feature_scoring.py +615 -0
  167. plugins/geocoding_resolver.py +1348 -0
  168. plugins/geometry_validator.py +875 -0
  169. plugins/local_raster_loader.py +365 -0
  170. plugins/local_vector_loader.py +547 -0
  171. plugins/ndvi_analysis.py +53 -0
  172. plugins/ndvi_calculator.py +563 -0
  173. plugins/nearest_neighbor.py +611 -0
  174. plugins/pdf_renderer.py +369 -0
  175. plugins/postgis_connector.py +1485 -0
  176. plugins/raster_clip_mask.py +954 -0
  177. plugins/raster_reclassify.py +743 -0
  178. plugins/raster_statistics.py +799 -0
  179. plugins/raster_threshold.py +773 -0
  180. plugins/raster_to_vector.py +993 -0
  181. plugins/real_estate_scoring.py +194 -0
  182. plugins/real_estate_spatial_enrichment.py +208 -0
  183. plugins/report_builder.py +613 -0
  184. plugins/risk_enrichment.py +471 -0
  185. plugins/slope_aspect.py +862 -0
  186. plugins/spatial_intersection.py +1099 -0
  187. plugins/spatial_join.py +802 -0
  188. plugins/spatial_predicate.py +453 -0
  189. plugins/spatial_query_filter.py +1060 -0
  190. plugins/spectral_indices.py +828 -0
  191. plugins/wms_wfs_fetcher.py +1012 -0
  192. plugins/zonal_statistics.py +928 -0
  193. smart_spatial_system/__init__.py +0 -0
  194. smart_spatial_system/__main__.py +13 -0
  195. smart_spatial_system/application/__init__.py +0 -0
  196. smart_spatial_system/application/services/__init__.py +0 -0
  197. smart_spatial_system/application/services/data_source_service.py +473 -0
  198. smart_spatial_system/application/services/feedback_proposal_service.py +239 -0
  199. smart_spatial_system/application/services/llm_intent_adapter.py +109 -0
  200. smart_spatial_system/application/services/map_layer_service.py +40 -0
  201. smart_spatial_system/application/services/output_service.py +44 -0
  202. smart_spatial_system/application/services/planning_execution_policy.py +162 -0
  203. smart_spatial_system/application/services/planning_response_adapter.py +212 -0
  204. smart_spatial_system/application/services/plugin_runtime_service.py +367 -0
  205. smart_spatial_system/application/services/project_service.py +167 -0
  206. smart_spatial_system/application/services/query_execution/__init__.py +0 -0
  207. smart_spatial_system/application/services/query_execution/direct_query_dispatch.py +84 -0
  208. smart_spatial_system/application/services/query_execution/natural_query_context.py +54 -0
  209. smart_spatial_system/application/services/query_execution/natural_query_dispatch.py +48 -0
  210. smart_spatial_system/application/services/query_execution/natural_query_execution.py +110 -0
  211. smart_spatial_system/application/services/query_execution/natural_query_failure.py +68 -0
  212. smart_spatial_system/application/services/query_execution/natural_query_persistence.py +69 -0
  213. smart_spatial_system/application/services/query_execution/planning_context.py +85 -0
  214. smart_spatial_system/application/services/query_execution/planning_execution.py +71 -0
  215. smart_spatial_system/application/services/query_execution/planning_persistence.py +100 -0
  216. smart_spatial_system/application/services/query_execution/planning_response.py +150 -0
  217. smart_spatial_system/application/services/query_execution/postgis_planning_context.py +514 -0
  218. smart_spatial_system/application/services/query_execution/real_estate_analysis_inspector.py +255 -0
  219. smart_spatial_system/application/services/query_execution/real_estate_classifier.py +151 -0
  220. smart_spatial_system/application/services/query_execution/real_estate_context.py +359 -0
  221. smart_spatial_system/application/services/query_execution/real_estate_document_renderer.py +165 -0
  222. smart_spatial_system/application/services/query_execution/real_estate_missing_inputs.py +132 -0
  223. smart_spatial_system/application/services/query_execution/real_estate_ranking_artifacts.py +99 -0
  224. smart_spatial_system/application/services/query_execution/real_estate_ranking_direct_handler.py +120 -0
  225. smart_spatial_system/application/services/query_execution/real_estate_ranking_execution.py +54 -0
  226. smart_spatial_system/application/services/query_execution/real_estate_ranking_query_spec.py +157 -0
  227. smart_spatial_system/application/services/query_execution/real_estate_ranking_response.py +231 -0
  228. smart_spatial_system/application/services/query_execution/real_estate_report_payload.py +108 -0
  229. smart_spatial_system/application/services/query_execution/real_estate_scoring.py +145 -0
  230. smart_spatial_system/application/services/query_execution_service.py +1408 -0
  231. smart_spatial_system/application/services/query_spec_enrichment.py +68 -0
  232. smart_spatial_system/application/services/real_estate_spatial_helpers.py +286 -0
  233. smart_spatial_system/application/services/request_history_service.py +82 -0
  234. smart_spatial_system/application/services/system_status_query_handler.py +243 -0
  235. smart_spatial_system/application/services/upload_service.py +56 -0
  236. smart_spatial_system/application/services/vector_display_handler.py +419 -0
  237. smart_spatial_system/application/services/vector_geojson_helpers.py +187 -0
  238. smart_spatial_system/application/services/vector_query_classifier.py +165 -0
  239. smart_spatial_system/cli.py +64 -0
  240. smart_spatial_system/domain/__init__.py +0 -0
  241. smart_spatial_system/domain/contracts/__init__.py +0 -0
  242. smart_spatial_system/domain/models/__init__.py +0 -0
  243. smart_spatial_system/infrastructure/__init__.py +0 -0
  244. smart_spatial_system/infrastructure/stores/__init__.py +0 -0
  245. smart_spatial_system/interfaces/__init__.py +0 -0
  246. smart_spatial_system/interfaces/api/__init__.py +0 -0
  247. smart_spatial_system/interfaces/api/routers/__init__.py +0 -0
  248. smart_spatial_system/plugins/__init__.py +0 -0
  249. smart_spatial_system/runtime/__init__.py +0 -0
  250. smart_spatial_system/shared/__init__.py +0 -0
  251. smart_spatial_system/workflows/__init__.py +0 -0
  252. smart_spatial_system/workflows/ndvi/__init__.py +0 -0
  253. smart_spatial_system/workflows/real_estate/__init__.py +0 -0
  254. smart_spatial_system-0.1.0.dist-info/METADATA +180 -0
  255. smart_spatial_system-0.1.0.dist-info/RECORD +262 -0
  256. smart_spatial_system-0.1.0.dist-info/WHEEL +5 -0
  257. smart_spatial_system-0.1.0.dist-info/entry_points.txt +2 -0
  258. smart_spatial_system-0.1.0.dist-info/licenses/LICENSE +21 -0
  259. smart_spatial_system-0.1.0.dist-info/top_level.txt +6 -0
  260. templates/__init__.py +9 -0
  261. templates/reports/__init__.py +6 -0
  262. 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()
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
+