Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tile server migration #262

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
8 changes: 5 additions & 3 deletions application/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
fact,
guidance_,
about_,
tiles_,
osMapOAuth,
)
from application.settings import get_settings
Expand Down Expand Up @@ -269,6 +270,7 @@ def add_routers(app):
app.include_router(map_.router, prefix="/map", include_in_schema=False)
app.include_router(guidance_.router, prefix="/guidance", include_in_schema=False)
app.include_router(about_.router, prefix="/about", include_in_schema=False)
app.include_router(tiles_.router, prefix="/tiles", include_in_schema=False)


def add_static(app):
Expand All @@ -291,9 +293,9 @@ def add_middleware(app):
@app.middleware("http")
async def add_strict_transport_security_header(request: Request, call_next):
response = await call_next(request)
response.headers[
"Strict-Transport-Security"
] = f"max-age={SECONDS_IN_TWO_YEARS}; includeSubDomains; preload"
response.headers["Strict-Transport-Security"] = (
f"max-age={SECONDS_IN_TWO_YEARS}; includeSubDomains; preload"
)
return response

@app.middleware("http")
Expand Down
161 changes: 161 additions & 0 deletions application/routers/tiles_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import logging

from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse

import psycopg2
from io import BytesIO

from application.settings import get_settings

router = APIRouter()
logger = logging.getLogger(__name__)

DATABASE = {"user": "", "password": "", "host": "", "port": "5432", "database": ""}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all database info should be managed from the db section this is clearly just development code that was user before. the db folder has the relevant stuff for connecting so don't use whatever is below


DATABASE_CONNECTION = None

QUERY_PARAMS = {
"table1": "entity t1",
"srid": "4326",
"geomColumn": "t1.geometry",
"attrColumns": "t1.entity, t1.name, t1.reference",
}


# ============================================================
# Helper Funcs
# ============================================================
def get_db_connection():
conn_str = get_settings()

DATABASE["user"] = conn_str.READ_DATABASE_URL.user
DATABASE["password"] = conn_str.READ_DATABASE_URL.password
DATABASE["host"] = conn_str.READ_DATABASE_URL.host
DATABASE["database"] = conn_str.READ_DATABASE_URL.path.split("/")[1]


get_db_connection()


# Do the tile x/y coordinates make sense at this zoom level?
def tile_is_valid(tile):
if not ("x" in tile and "y" in tile and "zoom" in tile):
return False

if "format" not in tile or tile["format"] not in ["pbf", "mvt"]:
return False

size = 2 ** tile["zoom"]

if tile["x"] >= size or tile["y"] >= size:
return False

if tile["x"] < 0 or tile["y"] < 0:
return False

return True


def build_db_query(tile):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should all be using sqlalchemy models not raw sql

qry_params = QUERY_PARAMS.copy()
qry_params["dataset"] = tile["dataset"]
qry_params["x"] = tile["x"]
qry_params["y"] = tile["y"]
qry_params["z"] = tile["zoom"]

query = """
WITH
webmercator(envelope) AS (
SELECT ST_TileEnvelope({z}, {x}, {y})
),
wgs84(envelope) AS (
SELECT ST_Transform((SELECT envelope FROM webmercator), {srid})
),
b(bounds) AS (
SELECT ST_MakeEnvelope(-180, -85.0511287798066, 180, 85.0511287798066, {srid})
),
geometries(entity, name, reference, wkb_geometry) AS (
SELECT
{attrColumns},
CASE WHEN ST_Covers(b.bounds, {geomColumn})
THEN ST_Transform({geomColumn},{srid})
ELSE ST_Transform(ST_Intersection(b.bounds, {geomColumn}),{srid})
END
FROM
{table1}
CROSS JOIN
b
WHERE
{geomColumn} && (SELECT envelope FROM wgs84)
AND
t1.dataset = '{dataset}'
)
SELECT
ST_AsMVT(tile, '{dataset}') as mvt
FROM (
SELECT
entity,
name,
reference,
ST_AsMVTGeom(wkb_geometry, (SELECT envelope FROM wgs84))
FROM geometries
) AS tile
""".format(
**qry_params
)

return query


def sql_to_pbf(sql):
global DATABASE_CONNECTION

# Make and hold connection to database
if not DATABASE_CONNECTION:
try:
DATABASE_CONNECTION = psycopg2.connect(**DATABASE)
except (Exception, psycopg2.Error) as error:
logger.warning(error)
return None

# Query for MVT
with DATABASE_CONNECTION.cursor() as cur:
cur.execute(sql)
if not cur:
logger.warning(f"sql query failed: {sql}")
return None

return cur.fetchone()[0]

return None


# ============================================================
# API Endpoints
# ============================================================


@router.get("/-/tiles/{dataset}/{z}/{x}/{y}.vector.{fmt}")
async def read_tiles_from_postgres(dataset: str, z: int, x: int, y: int, fmt: str):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is moderately useful as lays out the main router function but should be using different code underneath.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a question over the async nature of this, I'm not sure we use it elsewhere in the application. I'm not against it but would need to check it works okay.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure the router needs to be tiles_ it should probably just be tiles

tile = {"dataset": dataset, "zoom": z, "x": x, "y": y, "format": fmt}

if not tile_is_valid(tile):
raise HTTPException(status_code=400, detail=f"invalid tile path: {tile}")

sql = build_db_query(tile)

pbf = sql_to_pbf(sql)

pbf_buffer = BytesIO()
pbf_buffer.write(pbf)
pbf_buffer.seek(0)

resp_headers = {
"Access-Control-Allow-Origin": "*",
"Content-Type": "application/vnd.mapbox-vector-tile",
}

return StreamingResponse(
pbf_buffer, media_type="vnd.mapbox-vector-tile", headers=resp_headers
)
Loading
Loading