Skip to content

Commit 57525b1

Browse files
authored
feat: add license information to the api (#1482)
1 parent 15f05d3 commit 57525b1

File tree

16 files changed

+875
-139
lines changed

16 files changed

+875
-139
lines changed

.github/workflows/typescript-generator-check.yml

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,29 +44,14 @@ jobs:
4444
run: yarn generate:api-types:output
4545
env:
4646
OUTPUT_PATH_TYPES: src/app/services/feeds/generated/types.ts
47-
48-
- name: Generate TypeScript gbfs validator types
49-
working-directory: web-app
50-
run: yarn generate:gbfs-validator-types:output
51-
env:
52-
OUTPUT_PATH_TYPES: src/app/services/feeds/generated/gbfs-validator-types.ts
5347

5448
- name: Upload generated types
5549
uses: actions/upload-artifact@v4
5650
with:
5751
name: generated_types.ts
5852
path: web-app/src/app/services/feeds/generated/types.ts
59-
60-
- name: Upload generated gbfs types
61-
uses: actions/upload-artifact@v4
62-
with:
63-
name: generated_types.ts
64-
path: web-app/src/app/services/feeds/generated/gbfs-validator-types.ts
6553

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

70-
- name: Compare gbfs validator TypeScript types with existing types
71-
working-directory: web-app
72-
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)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Verify TypeScript GBFS Validator Types Generation
2+
on:
3+
pull_request:
4+
branches:
5+
- main
6+
paths:
7+
- "docs/GbfsValidator.yaml"
8+
9+
env:
10+
NODE_VERSION: "18"
11+
12+
jobs:
13+
generate-and-compare:
14+
runs-on: ubuntu-latest
15+
16+
steps:
17+
- name: Checkout code
18+
uses: actions/checkout@v4
19+
20+
- name: Setup Node.js
21+
uses: actions/setup-node@v4
22+
with:
23+
node-version: ${{ env.NODE_VERSION }}
24+
cache: 'yarn'
25+
cache-dependency-path: 'web-app/yarn.lock'
26+
27+
- name: Cache Yarn dependencies
28+
uses: actions/cache@v4
29+
id: yarn-cache
30+
with:
31+
path: |
32+
**/node_modules
33+
**/.eslintcache
34+
key: ${{ runner.os }}-yarn-${{ hashFiles('web-app/yarn.lock') }}
35+
restore-keys: |
36+
${{ runner.os }}-yarn-
37+
38+
- name: Install dependencies
39+
working-directory: web-app
40+
run: yarn install --frozen-lockfile --prefer-offline
41+
42+
- name: Generate TypeScript gbfs validator types
43+
working-directory: web-app
44+
run: yarn generate:gbfs-validator-types:output
45+
env:
46+
OUTPUT_PATH_TYPES: src/app/services/feeds/generated/gbfs-validator-types.ts
47+
48+
- name: Upload generated gbfs types
49+
uses: actions/upload-artifact@v4
50+
with:
51+
name: generated_types.ts
52+
path: web-app/src/app/services/feeds/generated/gbfs-validator-types.ts
53+
54+
- name: Compare gbfs validator TypeScript types with existing types
55+
working-directory: web-app
56+
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)

api/.openapi-generator/FILES

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ src/feeds_gen/apis/datasets_api.py
66
src/feeds_gen/apis/datasets_api_base.py
77
src/feeds_gen/apis/feeds_api.py
88
src/feeds_gen/apis/feeds_api_base.py
9+
src/feeds_gen/apis/licenses_api.py
10+
src/feeds_gen/apis/licenses_api_base.py
911
src/feeds_gen/apis/metadata_api.py
1012
src/feeds_gen/apis/metadata_api_base.py
1113
src/feeds_gen/apis/search_api.py
@@ -28,6 +30,9 @@ src/feeds_gen/models/gtfs_feed.py
2830
src/feeds_gen/models/gtfs_rt_feed.py
2931
src/feeds_gen/models/latest_dataset.py
3032
src/feeds_gen/models/latest_dataset_validation_report.py
33+
src/feeds_gen/models/license_base.py
34+
src/feeds_gen/models/license_rule.py
35+
src/feeds_gen/models/license_with_rules.py
3136
src/feeds_gen/models/location.py
3237
src/feeds_gen/models/metadata.py
3338
src/feeds_gen/models/redirect.py

api/src/feeds/impl/feeds_api_impl.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from sqlalchemy import or_
55
from sqlalchemy import select
6-
from sqlalchemy.orm import joinedload, Session
6+
from sqlalchemy.orm import joinedload, contains_eager, selectinload, Session
77
from sqlalchemy.orm.query import Query
88

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

75+
# Use an explicit LEFT OUTER JOIN and contains_eager so the License relationship
76+
# is populated from the same SQL result without causing N+1 queries.
7577
feed = (
7678
FeedFilter(stable_id=id, provider__ilike=None, producer_url__ilike=None, status=None)
7779
.filter(Database().get_query_model(db_session, FeedOrm))
80+
.outerjoin(FeedOrm.license)
81+
.options(contains_eager(FeedOrm.license))
7882
.filter(
7983
or_(
8084
FeedOrm.operational_status == "published",
@@ -115,7 +119,8 @@ def get_feeds(
115119
)
116120
# Results are sorted by provider
117121
feed_query = feed_query.order_by(FeedOrm.provider, FeedOrm.stable_id)
118-
feed_query = feed_query.options(*get_joinedload_options())
122+
# Ensure license relationship is available to the model conversion without extra queries
123+
feed_query = feed_query.options(*get_joinedload_options(), selectinload(FeedOrm.license))
119124
if limit is not None:
120125
feed_query = feed_query.limit(limit)
121126
if offset is not None:
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from typing import List, Optional
2+
3+
from feeds_gen.apis.licenses_api_base import BaseLicensesApi
4+
from feeds_gen.models.license_with_rules import LicenseWithRules
5+
from feeds_gen.models.license_base import LicenseBase
6+
from shared.database.database import with_db_session
7+
from shared.database_gen.sqlacodegen_models import License as LicenseOrm
8+
from feeds.impl.error_handling import raise_http_error
9+
from shared.db_models.license_with_rules_impl import LicenseWithRulesImpl
10+
from shared.db_models.license_base_impl import LicenseBaseImpl
11+
12+
13+
class LicensesApiImpl(BaseLicensesApi):
14+
"""
15+
Implementation for the Licenses API.
16+
"""
17+
18+
@with_db_session
19+
def get_license(self, id: str, db_session) -> LicenseWithRules:
20+
"""Return the license with the provided id."""
21+
try:
22+
lic: Optional[LicenseOrm] = db_session.query(LicenseOrm).filter(LicenseOrm.id == id).one_or_none()
23+
if not lic:
24+
raise_http_error(404, f"License '{id}' not found")
25+
26+
return LicenseWithRulesImpl.from_orm(lic)
27+
except Exception as e:
28+
# Use raise_http_error to convert into an HTTPException with proper logging
29+
raise_http_error(500, f"Error retrieving license: {e}")
30+
31+
@with_db_session
32+
def get_licenses(self, limit: int, offset: int, db_session) -> List[LicenseBase]:
33+
"""Return a list of licenses (paginated)."""
34+
try:
35+
query = db_session.query(LicenseOrm).order_by(LicenseOrm.id)
36+
if limit is not None:
37+
query = query.limit(limit)
38+
if offset is not None:
39+
query = query.offset(offset)
40+
results = query.all()
41+
42+
return [LicenseBaseImpl.from_orm(lic) for lic in results]
43+
except Exception as e:
44+
raise_http_error(500, f"Error retrieving licenses: {e}")

api/src/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from feeds_gen.apis.feeds_api import router as FeedsApiRouter
2525
from feeds_gen.apis.metadata_api import router as MetadataApiRouter
2626
from feeds_gen.apis.search_api import router as SearchApiRouter
27+
from feeds_gen.apis.licenses_api import router as LicensesApiRouter
2728

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

5860

5961
@app.on_event("startup")

api/src/scripts/populate_db_test_data.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
Gtfsfeed,
1313
Notice,
1414
Feature,
15+
License,
1516
t_feedsearch,
1617
Location,
1718
Officialstatushistory,
1819
Gbfsversion,
1920
Gbfsendpoint,
2021
Gbfsfeed,
22+
Rule,
2123
)
2224
from scripts.populate_db import set_up_configs, DatabasePopulateHelper
2325
from typing import TYPE_CHECKING
@@ -55,6 +57,51 @@ def populate_test_datasets(self, filepath, db_session: "Session"):
5557
with open(filepath) as f:
5658
data = json.load(f)
5759

60+
# Licenses (populate license table first so feeds can reference them)
61+
if "licenses" in data:
62+
for lic in data["licenses"]:
63+
# skip if id missing
64+
lic_id = lic.get("id")
65+
if not lic_id:
66+
continue
67+
existing = db_session.get(License, lic_id)
68+
if existing:
69+
# optionally update existing fields if needed
70+
continue
71+
license_obj = License(
72+
id=lic_id,
73+
type=lic.get("type", "standard"),
74+
is_spdx=lic.get("is_spdx", False),
75+
name=lic.get("name"),
76+
url=lic.get("url"),
77+
description=lic.get("description"),
78+
content_txt=lic.get("content_txt"),
79+
content_html=lic.get("content_html"),
80+
created_at=lic.get("created_at"),
81+
updated_at=lic.get("updated_at"),
82+
)
83+
db_session.add(license_obj)
84+
db_session.commit()
85+
86+
# Rules (optional section to seed rule metadata used by license_rules)
87+
if "rules" in data:
88+
for rule in data["rules"]:
89+
rule_name = rule.get("name")
90+
if not rule_name:
91+
continue
92+
existing_rule = db_session.get(Rule, rule_name)
93+
if existing_rule:
94+
continue
95+
db_session.add(
96+
Rule(
97+
name=rule_name,
98+
label=rule.get("label") or rule_name,
99+
type=rule.get("type") or "permission",
100+
description=rule.get("description"),
101+
)
102+
)
103+
db_session.commit()
104+
58105
# GTFS Feeds
59106
if "feeds" in data:
60107
self.populate_test_feeds(data["feeds"], db_session)
@@ -130,6 +177,29 @@ def populate_test_datasets(self, filepath, db_session: "Session"):
130177
db_session.query(Feature).filter(Feature.name == report_features["feature_name"]).first()
131178
)
132179

180+
# License rules: populate association table by creating missing Rule rows and attaching them to License
181+
if "license_rules" in data:
182+
for lr in data["license_rules"]:
183+
license_id = lr.get("license_id")
184+
rule_id = lr.get("rule_id")
185+
if not license_id or not rule_id:
186+
continue
187+
license_obj = db_session.get(License, license_id)
188+
if not license_obj:
189+
self.logger.error(f"No license found with id: {license_id}; skipping license_rule {rule_id}")
190+
continue
191+
rule_obj = db_session.get(Rule, rule_id)
192+
if not rule_obj:
193+
# Create a minimal Rule entry; label and type set conservatively
194+
rule_obj = Rule(name=rule_id, label=rule_id, type="permission", description=None)
195+
db_session.add(rule_obj)
196+
# flush so the relationship can reference it immediately
197+
db_session.flush()
198+
# Attach if not already associated
199+
if rule_obj not in license_obj.rules:
200+
license_obj.rules.append(rule_obj)
201+
db_session.commit()
202+
133203
# GBFS version
134204
if "gbfs_versions" in data:
135205
for version in data["gbfs_versions"]:
@@ -180,9 +250,13 @@ def populate_test_feeds(self, feeds_data, db_session: "Session"):
180250
note=feed_data["note"],
181251
authentication_info_url=None,
182252
api_key_parameter_name=None,
183-
license_url=None,
253+
license_url=feed_data["source_info"]["license_url"],
184254
feed_contact_email=feed_data["feed_contact_email"],
185255
producer_url=feed_data["source_info"]["producer_url"],
256+
# license_id may be missing or an empty string; coerce empty -> None to avoid FK violation
257+
license_id=(feed_data["source_info"].get("license_id") or None),
258+
# allow empty notes to stay as empty string; coerce if you prefer None
259+
license_notes=(feed_data["source_info"].get("license_notes") or None),
186260
operational_status="published",
187261
)
188262
locations = []

api/src/shared/db_models/basic_feed_impl.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ class Config:
2020
def from_orm(cls, feed: Feed | None) -> BasicFeed | None:
2121
if not feed:
2222
return None
23+
# Determine license_is_spdx from the related License ORM if available
24+
license_is_spdx = None
25+
if getattr(feed, "license", None) is not None:
26+
license_is_spdx = feed.license.is_spdx
27+
2328
return cls(
2429
id=feed.stable_id,
2530
data_type=feed.data_type,
@@ -35,6 +40,9 @@ def from_orm(cls, feed: Feed | None) -> BasicFeed | None:
3540
authentication_info_url=feed.authentication_info_url,
3641
api_key_parameter_name=feed.api_key_parameter_name,
3742
license_url=feed.license_url,
43+
license_id=feed.license_id,
44+
license_is_spdx=license_is_spdx,
45+
license_notes=feed.license_notes,
3846
),
3947
redirects=sorted([RedirectImpl.from_orm(item) for item in feed.redirectingids], key=lambda x: x.target_id),
4048
)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional
4+
5+
from feeds_gen.models.license_base import LicenseBase
6+
from pydantic import ConfigDict
7+
8+
from shared.database_gen.sqlacodegen_models import License as LicenseOrm
9+
10+
11+
class LicenseBaseImpl(LicenseBase):
12+
"""Pydantic model hydratable directly from a License ORM row."""
13+
14+
model_config = ConfigDict(from_attributes=True)
15+
16+
@classmethod
17+
def from_orm(cls, license_orm: Optional[LicenseOrm]) -> Optional[LicenseBase]:
18+
"""Convert a SQLAlchemy License row into the base License model."""
19+
if not license_orm:
20+
return None
21+
22+
return cls(
23+
id=license_orm.id,
24+
type=license_orm.type,
25+
is_spdx=license_orm.is_spdx,
26+
name=license_orm.name,
27+
url=license_orm.url,
28+
description=license_orm.description,
29+
created_at=license_orm.created_at,
30+
updated_at=license_orm.updated_at,
31+
)

0 commit comments

Comments
 (0)