diff --git a/data/map.mbtiles b/data/map.mbtiles index 97ca6fe..23f9262 100644 Binary files a/data/map.mbtiles and b/data/map.mbtiles differ diff --git a/data/raster.mbtiles b/data/raster.mbtiles new file mode 100644 index 0000000..d054e8d Binary files /dev/null and b/data/raster.mbtiles differ diff --git a/internal/controllers/rasterTileController.go b/internal/controllers/rasterTileController.go new file mode 100644 index 0000000..7a5f3bb --- /dev/null +++ b/internal/controllers/rasterTileController.go @@ -0,0 +1,113 @@ +package controllers + +import ( + "context" + "fmt" + "history-api/internal/dtos/response" + "history-api/internal/services" + "strconv" + "time" + + "github.com/gofiber/fiber/v3" +) + +type RasterTileController struct { + service services.RasterTileService +} + +func NewRasterTileController(svc services.RasterTileService) *RasterTileController { + return &RasterTileController{service: svc} +} + +// GetMetadata godoc +// @Summary Get raster tile metadata +// @Description Retrieve map metadata +// @Tags Tile +// @Accept json +// @Produce json +// @Success 200 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /raster-tiles/metadata [get] +func (h *RasterTileController) GetMetadata(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + res, err := h.service.GetMetadata(ctx) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + return c.Status(fiber.StatusOK).JSON(response.CommonResponse{ + Status: true, + Data: res, + }) +} + +// GetTile godoc +// @Summary Get a map raster tile +// @Description Fetch vector or raster map tile data by Z, X, Y coordinates +// @Tags Tile +// @Produce application/octet-stream +// @Param z path int true "Zoom level (0-22)" +// @Param x path int true "X coordinate" +// @Param y path int true "Y coordinate" +// @Success 200 {file} byte +// @Failure 400 {object} response.CommonResponse +// @Failure 500 {object} response.CommonResponse +// @Router /raster-tiles/{z}/{x}/{y} [get] +func (h *RasterTileController) GetTile(c fiber.Ctx) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + z, x, y, err := h.parseTileParams(c) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + data, headers, err := h.service.GetTile(ctx, z, x, y) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(response.CommonResponse{ + Status: false, + Message: err.Error(), + }) + } + + for k, v := range headers { + c.Set(k, v) + } + + return c.Status(fiber.StatusOK).Send(data) +} + +func (h *RasterTileController) parseTileParams(c fiber.Ctx) (int, int, int, error) { + z, err := strconv.Atoi(c.Params("z")) + if err != nil { + return 0, 0, 0, fmt.Errorf("invalid z") + } + + x, err := strconv.Atoi(c.Params("x")) + if err != nil { + return 0, 0, 0, fmt.Errorf("invalid x") + } + + y, err := strconv.Atoi(c.Params("y")) + if err != nil { + return 0, 0, 0, fmt.Errorf("invalid y") + } + + if z < 0 || x < 0 || y < 0 { + return 0, 0, 0, fmt.Errorf("coordinates must be positive") + } + + if z > 22 { + return 0, 0, 0, fmt.Errorf("zoom level too large") + } + + return z, x, y, nil +} diff --git a/internal/repositories/rasterTileRepository.go b/internal/repositories/rasterTileRepository.go new file mode 100644 index 0000000..3ae4f86 --- /dev/null +++ b/internal/repositories/rasterTileRepository.go @@ -0,0 +1,99 @@ +package repositories + +import ( + "context" + "database/sql" + "fmt" + "history-api/pkg/cache" + "history-api/pkg/constants" + "time" +) + +type RasterTileRepository interface { + GetMetadata(ctx context.Context) (map[string]string, error) + GetTile(ctx context.Context, z, x, y int) ([]byte, string, error) +} + +type rasterTileRepository struct { + db *sql.DB + c cache.Cache +} + +func NewRasterTileRepository(db *sql.DB, c cache.Cache) RasterTileRepository { + return &rasterTileRepository{ + db: db, + c: c, + } +} + +func (r *rasterTileRepository) GetMetadata(ctx context.Context) (map[string]string, error) { + cacheId := "rasterTile:metadata" + + var cached map[string]string + err := r.c.Get(ctx, cacheId, &cached) + if err == nil { + return cached, nil + } + + rows, err := r.db.QueryContext(ctx, "SELECT name, value FROM metadata") + if err != nil { + return nil, err + } + defer rows.Close() + + metadata := make(map[string]string) + + for rows.Next() { + var name, value string + if err := rows.Scan(&name, &value); err != nil { + return nil, err + } + metadata[name] = value + } + + _ = r.c.Set(ctx, cacheId, metadata, constants.NormalCacheDuration) + + return metadata, nil +} + +func (r *rasterTileRepository) GetTile(ctx context.Context, z, x, y int) ([]byte, string, error) { + if z < 0 || x < 0 || y < 0 { + return nil, "", fmt.Errorf("invalid tile coordinates") + } + + // cache key + cacheId := fmt.Sprintf("rasterTile:%d:%d:%d", z, x, y) + + var cached []byte + err := r.c.Get(ctx, cacheId, &cached) + if err == nil { + meta, _ := r.GetMetadata(ctx) + return cached, meta["format"], nil + } + + // XYZ -> TMS + tmsY := (1 << z) - 1 - y + + var tileData []byte + + err = r.db.QueryRowContext(ctx, ` + SELECT tile_data + FROM tiles + WHERE zoom_level = ? + AND tile_column = ? + AND tile_row = ? + `, z, x, tmsY).Scan(&tileData) + + if err != nil { + return nil, "", err + } + + meta, err := r.GetMetadata(ctx) + if err != nil { + return nil, "", err + } + + _ = r.c.Set(ctx, cacheId, tileData, 5*time.Minute) + + return tileData, meta["format"], nil +} diff --git a/internal/repositories/tileRepository.go b/internal/repositories/tileRepository.go index 04b8a75..91dd7e9 100644 --- a/internal/repositories/tileRepository.go +++ b/internal/repositories/tileRepository.go @@ -27,7 +27,7 @@ func NewTileRepository(db *sql.DB, c cache.Cache) TileRepository { } func (r *tileRepository) GetMetadata(ctx context.Context) (map[string]string, error) { - cacheId := "mbtiles:metadata" + cacheId := "tile:metadata" var cached map[string]string err := r.c.Get(ctx, cacheId, &cached) diff --git a/internal/routes/rasterTileRoute.go b/internal/routes/rasterTileRoute.go new file mode 100644 index 0000000..ed6d1a8 --- /dev/null +++ b/internal/routes/rasterTileRoute.go @@ -0,0 +1,13 @@ +package routes + +import ( + "history-api/internal/controllers" + + "github.com/gofiber/fiber/v3" +) + +func RasterTileRoutes(app *fiber.App, controller *controllers.RasterTileController) { + route := app.Group("/raster-tiles") + route.Get("/metadata", controller.GetMetadata) + route.Get("/:z/:x/:y", controller.GetTile) +} diff --git a/internal/services/rasterTileService.go b/internal/services/rasterTileService.go new file mode 100644 index 0000000..c3dfd50 --- /dev/null +++ b/internal/services/rasterTileService.go @@ -0,0 +1,57 @@ +package services + +import ( + "context" + "history-api/internal/repositories" + + "github.com/gofiber/fiber/v3" +) + +type RasterTileService interface { + GetMetadata(ctx context.Context) (map[string]string, error) + GetTile(ctx context.Context, z, x, y int) ([]byte, map[string]string, error) +} + +type rasterTileService struct { + tileRepo repositories.RasterTileRepository +} + +func NewRasterTileService( + TileRepo repositories.RasterTileRepository, +) RasterTileService { + return &rasterTileService{ + tileRepo: TileRepo, + } +} + +func (t *rasterTileService) GetMetadata(ctx context.Context) (map[string]string, error) { + metaData, err := t.tileRepo.GetMetadata(ctx) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return metaData, nil +} + + +func (t *rasterTileService) GetTile(ctx context.Context, z, x, y int) ([]byte, map[string]string, error) { + contentType := make(map[string]string) + + data, format, err := t.tileRepo.GetTile(ctx, z, x, y) + if err != nil { + return nil, contentType, fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + switch format { + case "png": + contentType["Content-Type"] = "image/png" + case "jpg", "jpeg": + contentType["Content-Type"] = "image/jpeg" + case "webp": + contentType["Content-Type"] = "image/webp" + default: + contentType["Content-Type"] = "application/octet-stream" + } + + return data, contentType, nil + +} diff --git a/internal/services/tileService.go b/internal/services/tileService.go index 6bd62e7..51c325a 100644 --- a/internal/services/tileService.go +++ b/internal/services/tileService.go @@ -40,12 +40,15 @@ func (t *tileService) GetTile(ctx context.Context, z, x, y int) ([]byte, map[str if err != nil { return nil, contentType, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } - contentType["Content-Type"] = "image/png" - if format == "jpg" { - contentType["Content-Type"] = "image/jpeg" - } - if format == "pbf" { + switch format { + case "pbf": contentType["Content-Type"] = "application/x-protobuf" + case "png": + contentType["Content-Type"] = "image/png" + case "jpg", "jpeg": + contentType["Content-Type"] = "image/jpeg" + default: + contentType["Content-Type"] = "application/octet-stream" } if isPBF {