diff --git a/Makefile b/Makefile index 1f5db66..74652fd 100644 --- a/Makefile +++ b/Makefile @@ -42,10 +42,6 @@ fmt: ## Format the code lint: ## Run golangci-lint (requires golangci-lint installed) golangci-lint run -.PHONY: docs -docs: ## Regenerate the OpenAPI (Swagger) spec from swag annotations - go tool swag init -g cmd/gis/main.go --parseInternal --output docs - .PHONY: tidy tidy: ## Tidy go.mod / go.sum go mod tidy diff --git a/README.md b/README.md index cd08302..358bce5 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ migrations/ embedded goose SQL migrations configs/ .env.example deployments/ docker-compose (postgres, minio, rabbitmq) build/package/ Dockerfile -docs/ generated OpenAPI/Swagger spec (swaggo/swag) +api/openapi.yaml OpenAPI 3.1.1 spec (embedded + served at /openapi.yaml) ``` ## Domain @@ -105,14 +105,10 @@ Health: `GET /healthz` (liveness), `GET /readyz` (DB + S3 + RabbitMQ). ### HTTP API -The API is documented with [swaggo/swag](https://github.com/swaggo/swag) -annotations on the handlers. The generated spec lives in `docs/` and is served -as interactive **Swagger UI** at `/swagger/index.html` while the server runs. -Regenerate after changing annotations: - -```sh -make docs # go tool swag init -g cmd/gis/main.go --parseInternal --output docs -``` +The API is described by an **OpenAPI 3.1.1** spec at +[`api/openapi.yaml`](api/openapi.yaml), embedded into the binary. While the +server runs it is served at `/openapi.yaml`, with an interactive **Redoc** UI at +`/docs`. | Method | Path | Description | |--------|----------------------------|--------------------------------------| diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..9f6eb7b --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,700 @@ +openapi: 3.1.1 +info: + title: GIS API + version: "1.0.0" + summary: Hierarchical categories and geo datasets with async processing. + description: | + HTTP API for hierarchical categories and geo datasets (vector, + vector_with_kato, raster). + + Datasets are processed asynchronously after upload, dispatched by `file_type`: + - `vector` — the attribute table is extracted into `properties`. + - `raster` — converted to a Cloud-Optimized GeoTIFF; footprint `geometry` + and `bbox` are derived from the raster extent. + - `vector_with_kato` — columns are detected for selection; the client then + submits a KATO/year mapping, unpivoted into observations. + + Poll `GET /datasets/{id}/status` (optionally long-polling) to follow progress + through the status lifecycle: + `pending → processing|parsing → awaiting_mapping → extracting → ready` + (or `failed`). + license: + name: Proprietary + +servers: + - url: http://localhost:8080 + description: Local development + +tags: + - name: Health + description: Liveness and readiness probes + - name: Categories + description: Hierarchical dataset categories + - name: Datasets + description: Geo dataset upload, processing, and retrieval + +paths: + /healthz: + get: + tags: [Health] + summary: Liveness probe + responses: + "200": + description: Process is alive + content: + application/json: + schema: + type: object + properties: + status: + type: string + + /readyz: + get: + tags: [Health] + summary: Readiness probe + description: Runs dependency checks (Postgres, S3/MinIO, RabbitMQ). + responses: + "200": + description: All dependencies reachable + content: + application/json: + schema: { $ref: "#/components/schemas/Readiness" } + "503": + description: One or more dependencies unavailable + content: + application/json: + schema: { $ref: "#/components/schemas/Readiness" } + + /categories: + get: + tags: [Categories] + summary: List categories + parameters: + - name: parent_id + in: query + required: false + description: Filter to the direct children of this category. + schema: + type: string + format: uuid + responses: + "200": + description: Categories ordered by name + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Category" } + "400": { $ref: "#/components/responses/BadRequest" } + post: + tags: [Categories] + summary: Create a category + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CategoryInput" } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/Category" } + "400": { $ref: "#/components/responses/BadRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/ValidationError" } + + /categories/{id}: + parameters: + - $ref: "#/components/parameters/IdParam" + get: + tags: [Categories] + summary: Get a category + responses: + "200": + description: The category + content: + application/json: + schema: { $ref: "#/components/schemas/Category" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + put: + tags: [Categories] + summary: Update a category + description: | + Updates name, description, and parent. The new parent must exist and must + not create a cycle (a category cannot be its own ancestor). + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CategoryInput" } + responses: + "200": + description: Updated + content: + application/json: + schema: { $ref: "#/components/schemas/Category" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [Categories] + summary: Delete a category + description: Fails with 409 if datasets or child categories still reference it. + responses: + "204": { description: Deleted } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + + /datasets: + get: + tags: [Datasets] + summary: List datasets (paginated summaries) + parameters: + - $ref: "#/components/parameters/PageParam" + - $ref: "#/components/parameters/PageSizeParam" + - name: category_id + in: query + required: false + description: Filter to a category. + schema: + type: string + format: uuid + responses: + "200": + description: A page of dataset summaries + content: + application/json: + schema: { $ref: "#/components/schemas/DatasetSummaryPage" } + "400": { $ref: "#/components/responses/BadRequest" } + post: + tags: [Datasets] + summary: Upload a dataset + description: | + Multipart upload, validated by `file_type`, extension, and a content + magic-byte check, then processed asynchronously (poll + `GET /datasets/{id}/status`). + + Allowed extensions: `vector`/`vector_with_kato` = `.geojson`/`.gpkg`/`.zip`; + `raster` = `.tif`/`.tiff`. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, file_type, category_id, code] + properties: + file: + type: string + format: binary + description: The geo file. + file_type: + $ref: "#/components/schemas/FileType" + category_id: + type: string + format: uuid + code: + type: string + description: Business code. + name: + type: string + description: Display name; defaults to the filename if omitted. + description: + type: string + unit: + type: string + meta: + type: string + description: Arbitrary user JSON (sent as a JSON string). + automated: + type: boolean + responses: + "201": + description: Dataset created; processing enqueued + content: + application/json: + schema: { $ref: "#/components/schemas/Dataset" } + "400": { $ref: "#/components/responses/BadRequest" } + "422": { $ref: "#/components/responses/ValidationError" } + + /datasets/{id}: + parameters: + - $ref: "#/components/parameters/IdParam" + get: + tags: [Datasets] + summary: Get a dataset + description: Full dataset, including geometry as GeoJSON and bbox for rasters. + responses: + "200": + description: The dataset + content: + application/json: + schema: { $ref: "#/components/schemas/Dataset" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [Datasets] + summary: Delete a dataset + description: Removes the row and the stored object(s). + responses: + "204": { description: Deleted } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + + /datasets/{id}/status: + parameters: + - $ref: "#/components/parameters/IdParam" + get: + tags: [Datasets] + summary: Get processing status (supports long polling) + description: | + Returns the current status immediately. If `current` is supplied, the + request is held until the status differs from it, or until `wait` seconds + elapse (then the unchanged status is returned). + parameters: + - name: current + in: query + required: false + description: The client's last-known status; enables long polling. + schema: { $ref: "#/components/schemas/DatasetStatus" } + - name: wait + in: query + required: false + description: Max seconds to hold the request (default 25, max 60). + schema: + type: integer + minimum: 0 + maximum: 60 + default: 25 + responses: + "200": + description: The dataset's status + content: + application/json: + schema: { $ref: "#/components/schemas/DatasetStatusInfo" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + + /datasets/{id}/download: + parameters: + - $ref: "#/components/parameters/IdParam" + get: + tags: [Datasets] + summary: Download the original uploaded file + responses: + "200": + description: The file stream + content: + application/octet-stream: + schema: + type: string + format: binary + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + + /datasets/{id}/mapping: + parameters: + - $ref: "#/components/parameters/IdParam" + post: + tags: [Datasets] + summary: Set the KATO column and year mapping (vector_with_kato) + description: | + Valid only for `vector_with_kato` datasets in `awaiting_mapping` (or + `ready`, to re-map). The KATO column and every mapped column must be among + the dataset's detected `attribute_columns`. Moves the dataset to + `extracting`; observations are produced asynchronously. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/MappingInput" } + responses: + "200": + description: Mapping saved; extraction enqueued + content: + application/json: + schema: { $ref: "#/components/schemas/Dataset" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/ValidationError" } + + /datasets/{id}/observations: + parameters: + - $ref: "#/components/parameters/IdParam" + get: + tags: [Datasets] + summary: List a dataset's observations (paginated) + description: Long-format values unpivoted from a vector_with_kato dataset. + parameters: + - name: kato_code + in: query + required: false + description: Filter to a single KATO code. + schema: + type: string + - $ref: "#/components/parameters/PageParam" + - $ref: "#/components/parameters/PageSizeParam" + responses: + "200": + description: A page of observations + content: + application/json: + schema: { $ref: "#/components/schemas/ObservationPage" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + +components: + parameters: + IdParam: + name: id + in: path + required: true + schema: + type: string + format: uuid + PageParam: + name: page + in: query + required: false + description: 1-based page number. + schema: + type: integer + minimum: 1 + default: 1 + PageSizeParam: + name: page_size + in: query + required: false + description: Items per page (default 20, max 100). + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + + responses: + BadRequest: + description: Malformed request (e.g. invalid UUID or query parameter) + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + NotFound: + description: Resource not found + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Conflict: + description: Operation conflicts with existing data (e.g. foreign key) + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ValidationError: + description: Input failed validation + content: + application/json: + schema: + oneOf: + - { $ref: "#/components/schemas/Error" } + - { $ref: "#/components/schemas/ValidationErrors" } + + schemas: + Error: + type: object + required: [error] + properties: + error: + type: string + + ValidationErrors: + type: object + description: Per-field validation messages. + properties: + errors: + type: object + additionalProperties: + type: string + + Readiness: + type: object + properties: + ready: + type: boolean + components: + type: object + additionalProperties: + type: string + + FileType: + type: string + enum: [vector_with_kato, vector, raster] + + DatasetStatus: + type: string + description: Dataset processing lifecycle status. + enum: [pending, processing, parsing, awaiting_mapping, extracting, ready, failed] + + Category: + type: object + required: [id, name, description, created_at, updated_at] + properties: + id: + type: string + format: uuid + parent_id: + type: [string, "null"] + format: uuid + name: + type: string + description: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + CategoryInput: + type: object + required: [name] + properties: + parent_id: + type: [string, "null"] + format: uuid + name: + type: string + maxLength: 255 + description: + type: string + maxLength: 2000 + + AttributeColumn: + type: object + required: [name] + properties: + name: + type: string + samples: + type: array + items: + type: string + + YearColumn: + type: object + required: [column, date] + properties: + column: + type: string + date: + type: string + format: date + + GeoJSONGeometry: + type: [object, "null"] + description: A GeoJSON geometry object (e.g. Point, Polygon). + properties: + type: + type: string + coordinates: true + + DatasetSummary: + type: object + required: [id, category_id, code, name, file_type, size_bytes, status, created_at, updated_at] + properties: + id: + type: string + format: uuid + category_id: + type: string + format: uuid + code: + type: string + name: + type: string + description: + type: [string, "null"] + unit: + type: [string, "null"] + file_type: + $ref: "#/components/schemas/FileType" + size_bytes: + type: integer + format: int64 + status: + $ref: "#/components/schemas/DatasetStatus" + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + Dataset: + type: object + required: + [id, category_id, code, name, filename, storage_key, file_type, + size_bytes, content_type, automated, status, created_at, updated_at] + properties: + id: + type: string + format: uuid + category_id: + type: string + format: uuid + code: + type: string + name: + type: string + description: + type: [string, "null"] + unit: + type: [string, "null"] + filename: + type: string + storage_key: + type: string + cog_storage_key: + type: [string, "null"] + description: Set for rasters once the Cloud-Optimized GeoTIFF is produced. + file_type: + $ref: "#/components/schemas/FileType" + size_bytes: + type: integer + format: int64 + content_type: + type: string + properties: + type: [array, "null"] + description: Extracted attribute table (plain vector); rows of key/value. + items: + type: object + additionalProperties: + type: string + meta: + type: [object, "null"] + description: Arbitrary user-supplied JSON. + automated: + type: boolean + status: + $ref: "#/components/schemas/DatasetStatus" + attribute_columns: + type: [array, "null"] + description: Detected columns (vector_with_kato), for mapping. + items: + $ref: "#/components/schemas/AttributeColumn" + kato_column: + type: [string, "null"] + year_columns: + type: [array, "null"] + items: + $ref: "#/components/schemas/YearColumn" + parse_error: + type: [string, "null"] + description: Failure reason when status is `failed`. + geometry: + $ref: "#/components/schemas/GeoJSONGeometry" + bbox: + type: array + description: "[minX, minY, maxX, maxY]; present only for rasters." + items: + type: number + minItems: 4 + maxItems: 4 + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + MappingInput: + type: object + required: [kato_column, year_columns] + properties: + kato_column: + type: string + examples: ["като"] + year_columns: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/YearColumn" + examples: + - kato_column: "като" + year_columns: + - { column: F_2023, date: "2023-01-01" } + - { column: D_2025, date: "2025-01-01" } + + Observation: + type: object + required: [id, dataset_id, kato_code, date] + properties: + id: + type: string + format: uuid + dataset_id: + type: string + format: uuid + kato_code: + type: string + date: + type: string + format: date + value: + type: [number, "null"] + description: Numeric cell value, or null when non-numeric/empty. + value_text: + type: [string, "null"] + description: Non-numeric cell value, or null. + + DatasetStatusInfo: + type: object + required: [id, status] + properties: + id: + type: string + format: uuid + status: + $ref: "#/components/schemas/DatasetStatus" + parse_error: + type: [string, "null"] + + DatasetSummaryPage: + type: object + required: [data, page, page_size, total, total_pages] + properties: + data: + type: array + items: + $ref: "#/components/schemas/DatasetSummary" + page: + type: integer + page_size: + type: integer + total: + type: integer + total_pages: + type: integer + + ObservationPage: + type: object + required: [data, page, page_size, total, total_pages] + properties: + data: + type: array + items: + $ref: "#/components/schemas/Observation" + page: + type: integer + page_size: + type: integer + total: + type: integer + total_pages: + type: integer diff --git a/api/spec.go b/api/spec.go new file mode 100644 index 0000000..8b29bec --- /dev/null +++ b/api/spec.go @@ -0,0 +1,9 @@ +// Package api embeds the OpenAPI 3.1.1 specification for the HTTP API. +package api + +import _ "embed" + +// Spec is the OpenAPI 3.1.1 document (YAML). +// +//go:embed openapi.yaml +var Spec []byte diff --git a/internal/transport/http/docs.go b/internal/transport/http/docs.go new file mode 100644 index 0000000..88b1644 --- /dev/null +++ b/internal/transport/http/docs.go @@ -0,0 +1,31 @@ +package http + +import "net/http" + +// redocHTML renders the OpenAPI spec with Redoc (loaded from a CDN), which +// supports OpenAPI 3.1. +const redocHTML = ` + + + GIS API + + + + + + + + +` + +// openAPISpec serves the embedded OpenAPI 3.1.1 document. +func (deps RouterDeps) openAPISpec(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write(deps.OpenAPISpec) +} + +// docsUI serves the Redoc documentation page. +func docsUI(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(redocHTML)) +}