Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions .github/workflows/typescript-generator-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,29 +44,14 @@ jobs:
run: yarn generate:api-types:output
env:
OUTPUT_PATH_TYPES: src/app/services/feeds/generated/types.ts

- name: Generate TypeScript gbfs validator types
working-directory: web-app
run: yarn generate:gbfs-validator-types:output
env:
OUTPUT_PATH_TYPES: src/app/services/feeds/generated/gbfs-validator-types.ts

- name: Upload generated types
uses: actions/upload-artifact@v4
with:
name: generated_types.ts
path: web-app/src/app/services/feeds/generated/types.ts

- name: Upload generated gbfs types
uses: actions/upload-artifact@v4
with:
name: generated_types.ts
path: web-app/src/app/services/feeds/generated/gbfs-validator-types.ts

- name: Compare TypeScript types with existing types
working-directory: web-app
run: diff src/app/services/feeds/generated/types.ts src/app/services/feeds/types.ts || (echo "Types are different!" && exit 1)

- name: Compare gbfs validator TypeScript types with existing types
working-directory: web-app
run: diff src/app/services/feeds/generated/gbfs-validator-types.ts src/app/services/feeds/gbfs-validator-types.ts || (echo "Gbfs Validator Types are different!" && exit 1)
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Verify TypeScript GBFS Validator Types Generation
Copy link
Member

Choose a reason for hiding this comment

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

👍

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thanks to @Alessandro100

on:
pull_request:
branches:
- main
paths:
- "docs/GbfsValidator.yaml"

env:
NODE_VERSION: "18"

jobs:
generate-and-compare:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'yarn'
cache-dependency-path: 'web-app/yarn.lock'

- name: Cache Yarn dependencies
uses: actions/cache@v4
id: yarn-cache
with:
path: |
**/node_modules
**/.eslintcache
key: ${{ runner.os }}-yarn-${{ hashFiles('web-app/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-

- name: Install dependencies
working-directory: web-app
run: yarn install --frozen-lockfile --prefer-offline

- name: Generate TypeScript gbfs validator types
working-directory: web-app
run: yarn generate:gbfs-validator-types:output
env:
OUTPUT_PATH_TYPES: src/app/services/feeds/generated/gbfs-validator-types.ts

- name: Upload generated gbfs types
uses: actions/upload-artifact@v4
with:
name: generated_types.ts
path: web-app/src/app/services/feeds/generated/gbfs-validator-types.ts

- name: Compare gbfs validator TypeScript types with existing types
working-directory: web-app
run: diff src/app/services/feeds/generated/gbfs-validator-types.ts src/app/services/feeds/gbfs-validator-types.ts || (echo "Gbfs Validator Types are different!" && exit 1)
5 changes: 5 additions & 0 deletions api/.openapi-generator/FILES
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ src/feeds_gen/apis/datasets_api.py
src/feeds_gen/apis/datasets_api_base.py
src/feeds_gen/apis/feeds_api.py
src/feeds_gen/apis/feeds_api_base.py
src/feeds_gen/apis/licenses_api.py
src/feeds_gen/apis/licenses_api_base.py
src/feeds_gen/apis/metadata_api.py
src/feeds_gen/apis/metadata_api_base.py
src/feeds_gen/apis/search_api.py
Expand All @@ -28,6 +30,9 @@ src/feeds_gen/models/gtfs_feed.py
src/feeds_gen/models/gtfs_rt_feed.py
src/feeds_gen/models/latest_dataset.py
src/feeds_gen/models/latest_dataset_validation_report.py
src/feeds_gen/models/license_base.py
src/feeds_gen/models/license_rule.py
src/feeds_gen/models/license_with_rules.py
src/feeds_gen/models/location.py
src/feeds_gen/models/metadata.py
src/feeds_gen/models/redirect.py
Expand Down
9 changes: 7 additions & 2 deletions api/src/feeds/impl/feeds_api_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from sqlalchemy import or_
from sqlalchemy import select
from sqlalchemy.orm import joinedload, Session
from sqlalchemy.orm import joinedload, contains_eager, selectinload, Session
from sqlalchemy.orm.query import Query

from feeds.impl.datasets_api_impl import DatasetsApiImpl
Expand Down Expand Up @@ -72,9 +72,13 @@ def get_feed(self, id: str, db_session: Session) -> Feed:
is_email_restricted = is_user_email_restricted()
self.logger.debug(f"User email is restricted: {is_email_restricted}")

# Use an explicit LEFT OUTER JOIN and contains_eager so the License relationship
# is populated from the same SQL result without causing N+1 queries.
feed = (
FeedFilter(stable_id=id, provider__ilike=None, producer_url__ilike=None, status=None)
.filter(Database().get_query_model(db_session, FeedOrm))
.outerjoin(FeedOrm.license)
.options(contains_eager(FeedOrm.license))
.filter(
or_(
FeedOrm.operational_status == "published",
Expand Down Expand Up @@ -115,7 +119,8 @@ def get_feeds(
)
# Results are sorted by provider
feed_query = feed_query.order_by(FeedOrm.provider, FeedOrm.stable_id)
feed_query = feed_query.options(*get_joinedload_options())
# Ensure license relationship is available to the model conversion without extra queries
feed_query = feed_query.options(*get_joinedload_options(), selectinload(FeedOrm.license))
if limit is not None:
feed_query = feed_query.limit(limit)
if offset is not None:
Expand Down
44 changes: 44 additions & 0 deletions api/src/feeds/impl/licenses_api_impl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from typing import List, Optional

from feeds_gen.apis.licenses_api_base import BaseLicensesApi
from feeds_gen.models.license_with_rules import LicenseWithRules
from feeds_gen.models.license_base import LicenseBase
from shared.database.database import with_db_session
from shared.database_gen.sqlacodegen_models import License as LicenseOrm
from feeds.impl.error_handling import raise_http_error
from shared.db_models.license_with_rules_impl import LicenseWithRulesImpl
from shared.db_models.license_base_impl import LicenseBaseImpl


class LicensesApiImpl(BaseLicensesApi):
"""
Implementation for the Licenses API.
"""

@with_db_session
def get_license(self, id: str, db_session) -> LicenseWithRules:
"""Return the license with the provided id."""
try:
lic: Optional[LicenseOrm] = db_session.query(LicenseOrm).filter(LicenseOrm.id == id).one_or_none()
if not lic:
raise_http_error(404, f"License '{id}' not found")

return LicenseWithRulesImpl.from_orm(lic)
except Exception as e:
# Use raise_http_error to convert into an HTTPException with proper logging
raise_http_error(500, f"Error retrieving license: {e}")

@with_db_session
def get_licenses(self, limit: int, offset: int, db_session) -> List[LicenseBase]:
"""Return a list of licenses (paginated)."""
try:
query = db_session.query(LicenseOrm).order_by(LicenseOrm.id)
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
results = query.all()

return [LicenseBaseImpl.from_orm(lic) for lic in results]
except Exception as e:
raise_http_error(500, f"Error retrieving licenses: {e}")
2 changes: 2 additions & 0 deletions api/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from feeds_gen.apis.feeds_api import router as FeedsApiRouter
from feeds_gen.apis.metadata_api import router as MetadataApiRouter
from feeds_gen.apis.search_api import router as SearchApiRouter
from feeds_gen.apis.licenses_api import router as LicensesApiRouter

# Using the starlettte implementaiton as fastapi implementation generates errors with CORS in certain situations and
# returns 200 in the method response. More info, https://github.com/tiangolo/fastapi/issues/1663#issuecomment-730362611
Expand Down Expand Up @@ -54,6 +55,7 @@
app.include_router(FeedsApiRouter)
app.include_router(MetadataApiRouter)
app.include_router(SearchApiRouter)
app.include_router(LicensesApiRouter)


@app.on_event("startup")
Expand Down
76 changes: 75 additions & 1 deletion api/src/scripts/populate_db_test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
Gtfsfeed,
Notice,
Feature,
License,
t_feedsearch,
Location,
Officialstatushistory,
Gbfsversion,
Gbfsendpoint,
Gbfsfeed,
Rule,
)
from scripts.populate_db import set_up_configs, DatabasePopulateHelper
from typing import TYPE_CHECKING
Expand Down Expand Up @@ -55,6 +57,51 @@ def populate_test_datasets(self, filepath, db_session: "Session"):
with open(filepath) as f:
data = json.load(f)

# Licenses (populate license table first so feeds can reference them)
if "licenses" in data:
for lic in data["licenses"]:
# skip if id missing
lic_id = lic.get("id")
if not lic_id:
continue
existing = db_session.get(License, lic_id)
if existing:
# optionally update existing fields if needed
continue
license_obj = License(
id=lic_id,
type=lic.get("type", "standard"),
is_spdx=lic.get("is_spdx", False),
name=lic.get("name"),
url=lic.get("url"),
description=lic.get("description"),
content_txt=lic.get("content_txt"),
content_html=lic.get("content_html"),
created_at=lic.get("created_at"),
updated_at=lic.get("updated_at"),
)
db_session.add(license_obj)
db_session.commit()

# Rules (optional section to seed rule metadata used by license_rules)
if "rules" in data:
for rule in data["rules"]:
rule_name = rule.get("name")
if not rule_name:
continue
existing_rule = db_session.get(Rule, rule_name)
if existing_rule:
continue
db_session.add(
Rule(
name=rule_name,
label=rule.get("label") or rule_name,
type=rule.get("type") or "permission",
description=rule.get("description"),
)
)
db_session.commit()

# GTFS Feeds
if "feeds" in data:
self.populate_test_feeds(data["feeds"], db_session)
Expand Down Expand Up @@ -130,6 +177,29 @@ def populate_test_datasets(self, filepath, db_session: "Session"):
db_session.query(Feature).filter(Feature.name == report_features["feature_name"]).first()
)

# License rules: populate association table by creating missing Rule rows and attaching them to License
if "license_rules" in data:
for lr in data["license_rules"]:
license_id = lr.get("license_id")
rule_id = lr.get("rule_id")
if not license_id or not rule_id:
continue
license_obj = db_session.get(License, license_id)
if not license_obj:
self.logger.error(f"No license found with id: {license_id}; skipping license_rule {rule_id}")
continue
rule_obj = db_session.get(Rule, rule_id)
if not rule_obj:
# Create a minimal Rule entry; label and type set conservatively
rule_obj = Rule(name=rule_id, label=rule_id, type="permission", description=None)
db_session.add(rule_obj)
# flush so the relationship can reference it immediately
db_session.flush()
# Attach if not already associated
if rule_obj not in license_obj.rules:
license_obj.rules.append(rule_obj)
db_session.commit()

# GBFS version
if "gbfs_versions" in data:
for version in data["gbfs_versions"]:
Expand Down Expand Up @@ -180,9 +250,13 @@ def populate_test_feeds(self, feeds_data, db_session: "Session"):
note=feed_data["note"],
authentication_info_url=None,
api_key_parameter_name=None,
license_url=None,
license_url=feed_data["source_info"]["license_url"],
feed_contact_email=feed_data["feed_contact_email"],
producer_url=feed_data["source_info"]["producer_url"],
# license_id may be missing or an empty string; coerce empty -> None to avoid FK violation
license_id=(feed_data["source_info"].get("license_id") or None),
# allow empty notes to stay as empty string; coerce if you prefer None
license_notes=(feed_data["source_info"].get("license_notes") or None),
operational_status="published",
)
locations = []
Expand Down
8 changes: 8 additions & 0 deletions api/src/shared/db_models/basic_feed_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ class Config:
def from_orm(cls, feed: Feed | None) -> BasicFeed | None:
if not feed:
return None
# Determine license_is_spdx from the related License ORM if available
license_is_spdx = None
if getattr(feed, "license", None) is not None:
license_is_spdx = feed.license.is_spdx

return cls(
id=feed.stable_id,
data_type=feed.data_type,
Expand All @@ -35,6 +40,9 @@ def from_orm(cls, feed: Feed | None) -> BasicFeed | None:
authentication_info_url=feed.authentication_info_url,
api_key_parameter_name=feed.api_key_parameter_name,
license_url=feed.license_url,
license_id=feed.license_id,
license_is_spdx=license_is_spdx,
license_notes=feed.license_notes,
),
redirects=sorted([RedirectImpl.from_orm(item) for item in feed.redirectingids], key=lambda x: x.target_id),
)
Expand Down
31 changes: 31 additions & 0 deletions api/src/shared/db_models/license_base_impl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from __future__ import annotations

from typing import Optional

from feeds_gen.models.license_base import LicenseBase
from pydantic import ConfigDict

from shared.database_gen.sqlacodegen_models import License as LicenseOrm


class LicenseBaseImpl(LicenseBase):
"""Pydantic model hydratable directly from a License ORM row."""

model_config = ConfigDict(from_attributes=True)

@classmethod
def from_orm(cls, license_orm: Optional[LicenseOrm]) -> Optional[LicenseBase]:
"""Convert a SQLAlchemy License row into the base License model."""
if not license_orm:
return None

return cls(
id=license_orm.id,
type=license_orm.type,
is_spdx=license_orm.is_spdx,
name=license_orm.name,
url=license_orm.url,
description=license_orm.description,
created_at=license_orm.created_at,
updated_at=license_orm.updated_at,
)
Loading
Loading