diff --git a/.github/workflows/typescript-generator-check.yml b/.github/workflows/typescript-generator-check.yml index 100700435..1ee4277f1 100644 --- a/.github/workflows/typescript-generator-check.yml +++ b/.github/workflows/typescript-generator-check.yml @@ -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) diff --git a/.github/workflows/typescript-generator-gbfs-validator-types-check.yml b/.github/workflows/typescript-generator-gbfs-validator-types-check.yml new file mode 100644 index 000000000..0a25a921e --- /dev/null +++ b/.github/workflows/typescript-generator-gbfs-validator-types-check.yml @@ -0,0 +1,56 @@ +name: Verify TypeScript GBFS Validator Types Generation +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) diff --git a/api/.openapi-generator/FILES b/api/.openapi-generator/FILES index 008305ccf..54483438a 100644 --- a/api/.openapi-generator/FILES +++ b/api/.openapi-generator/FILES @@ -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 @@ -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 diff --git a/api/src/feeds/impl/feeds_api_impl.py b/api/src/feeds/impl/feeds_api_impl.py index 2812e5d2f..67ce62098 100644 --- a/api/src/feeds/impl/feeds_api_impl.py +++ b/api/src/feeds/impl/feeds_api_impl.py @@ -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 @@ -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", @@ -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: diff --git a/api/src/feeds/impl/licenses_api_impl.py b/api/src/feeds/impl/licenses_api_impl.py new file mode 100644 index 000000000..39e0ef15c --- /dev/null +++ b/api/src/feeds/impl/licenses_api_impl.py @@ -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}") diff --git a/api/src/main.py b/api/src/main.py index da3cb76a2..98a227611 100644 --- a/api/src/main.py +++ b/api/src/main.py @@ -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 @@ -54,6 +55,7 @@ app.include_router(FeedsApiRouter) app.include_router(MetadataApiRouter) app.include_router(SearchApiRouter) +app.include_router(LicensesApiRouter) @app.on_event("startup") diff --git a/api/src/scripts/populate_db_test_data.py b/api/src/scripts/populate_db_test_data.py index 9e1af1bf4..d0ffe6f34 100644 --- a/api/src/scripts/populate_db_test_data.py +++ b/api/src/scripts/populate_db_test_data.py @@ -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 @@ -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) @@ -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"]: @@ -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 = [] diff --git a/api/src/shared/db_models/basic_feed_impl.py b/api/src/shared/db_models/basic_feed_impl.py index 47b6d90af..e865d7d79 100644 --- a/api/src/shared/db_models/basic_feed_impl.py +++ b/api/src/shared/db_models/basic_feed_impl.py @@ -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, @@ -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), ) diff --git a/api/src/shared/db_models/license_base_impl.py b/api/src/shared/db_models/license_base_impl.py new file mode 100644 index 000000000..968cbd35a --- /dev/null +++ b/api/src/shared/db_models/license_base_impl.py @@ -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, + ) diff --git a/api/src/shared/db_models/license_with_rules_impl.py b/api/src/shared/db_models/license_with_rules_impl.py new file mode 100644 index 000000000..700ffc096 --- /dev/null +++ b/api/src/shared/db_models/license_with_rules_impl.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import List, Optional + +from feeds_gen.models.license_rule import LicenseRule +from feeds_gen.models.license_with_rules import LicenseWithRules +from pydantic import ConfigDict + +from shared.database_gen.sqlacodegen_models import License as LicenseOrm +from shared.db_models.license_base_impl import LicenseBaseImpl + + +class LicenseWithRulesImpl(LicenseWithRules): + """Pydantic model that can be hydrated directly from a License ORM row.""" + + model_config = ConfigDict(from_attributes=True) + + @classmethod + def from_orm(cls, license_orm: Optional[LicenseOrm]) -> Optional[LicenseWithRules]: + """Convert a SQLAlchemy License row into a LicenseWithRules model.""" + if not license_orm: + return None + + base_license = LicenseBaseImpl.from_orm(license_orm) + rules: List[LicenseRule] = [ + LicenseRule( + name=rule.name, + label=rule.label, + description=rule.description, + type=rule.type, + ) + for rule in getattr(license_orm, "rules", []) + ] + + return cls( + **base_license.model_dump(), + license_rules=rules or [], + ) diff --git a/api/tests/integration/test_data/extra_test_data.json b/api/tests/integration/test_data/extra_test_data.json index 68f247782..d0723902b 100644 --- a/api/tests/integration/test_data/extra_test_data.json +++ b/api/tests/integration/test_data/extra_test_data.json @@ -1,4 +1,85 @@ { + "licenses": [ + { + "id": "license-1", + "type": "standard", + "is_spdx": true, + "name": "License 1 name", + "url": "https://license-1", + "description": "This is license-1", + "content_txt": "", + "content_html": "", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z" + + }, + { + "id": "license-2", + "type": "standard", + "is_spdx": false, + "name": "License 2 name", + "url": "https://license-2", + "description": "This is license-2", + "content_txt": "", + "content_html": "", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z" + + }, + { + "id": "license-3", + "type": "standard", + "is_spdx": false, + "name": "License 3 name", + "url": "https://license-3", + "description": "This is license-3 without rules", + "content_txt": "", + "content_html": "", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T00:00:00Z" + } + ], + + "rules": [ + { + "name": "license-rule-1", + "label": "license-rule-1-label", + "description": "Rule 1 description", + "type": "permission" + }, + { + "name": "license-rule-2", + "label": "license-rule-2-label", + "description": "Rule 2 description", + "type": "condition" + }, + { + "name": "license-rule-3", + "label": "license-rule-3-label", + "description": "Rule 3 description", + "type": "limitation" + } + ], + + "license_rules": [ + { + "license_id": "license-1", + "rule_id": "license-rule-1" + }, + { + "license_id": "license-1", + "rule_id": "license-rule-2" + }, + { + "license_id": "license-2", + "rule_id": "license-rule-2" + }, + { + "license_id": "license-2", + "rule_id": "license-rule-3" + } + ], + "feeds": [ { "id": "mdb-60", @@ -53,7 +134,9 @@ "authentication_type": 0, "authentication_info_url": "", "api_key_parameter_name": "", - "license_url": "" + "license_url": "allo", + "license_id": "license-1", + "license_notes": "Notes for license-1" }, "locations": [ { @@ -91,7 +174,8 @@ "authentication_type": 0, "authentication_info_url": "", "api_key_parameter_name": "", - "license_url": "" + "license_url": "", + "license_id": "license-2" }, "locations": [ { diff --git a/api/tests/integration/test_feeds_api.py b/api/tests/integration/test_feeds_api.py index 8f09378de..8c53a6d5a 100644 --- a/api/tests/integration/test_feeds_api.py +++ b/api/tests/integration/test_feeds_api.py @@ -944,3 +944,33 @@ def test_gbfs_feed_id_get(client: TestClient, values): if values["response_code"] != 200: return assert response.json()["id"] == test_id + + +@pytest.mark.parametrize( + "feed_id, expected_license_id, expected_is_spdx, expected_license_notes", + [ + ("mdb-70", "license-1", True, "Notes for license-1"), + ("mdb-80", "license-2", False, None), + ], +) +def test_feeds_have_expected_license_info( + client: TestClient, feed_id: str, expected_license_id: str, expected_is_spdx: bool, expected_license_notes: str +): + """ + Verify that specified feeds have the expected license id, + license_is_spdx and license_notes from the test fixture. + """ + response = client.request( + "GET", + "/v1/feeds/{id}".format(id=feed_id), + headers=authHeaders, + ) + + assert response.status_code == 200 + body = response.json() + # Ensure the license_id matches the expected license + assert body["source_info"]["license_id"] == expected_license_id + # Ensure the license_is_spdx flag matches expectation + assert body["source_info"]["license_is_spdx"] is expected_is_spdx + # Check license_notes (may be None) + assert body["source_info"].get("license_notes") == expected_license_notes diff --git a/api/tests/integration/test_licenses_api.py b/api/tests/integration/test_licenses_api.py new file mode 100644 index 000000000..bb5c4822c --- /dev/null +++ b/api/tests/integration/test_licenses_api.py @@ -0,0 +1,99 @@ +# coding: utf-8 +import pytest +from fastapi.testclient import TestClient + +from tests.test_utils.token import authHeaders + + +@pytest.mark.parametrize( + "license_id, expected_is_spdx, expected_name, expected_url, expected_description, expected_rules", + [ + ( + "license-1", + True, + "License 1 name", + "https://license-1", + "This is license-1", + [ + { + "name": "license-rule-1", + "label": "license-rule-1-label", + "description": "Rule 1 description", + "type": "permission", + }, + { + "name": "license-rule-2", + "label": "license-rule-2-label", + "description": "Rule 2 description", + "type": "condition", + }, + ], + ), + ( + "license-2", + False, + "License 2 name", + "https://license-2", + "This is license-2", + [ + { + "name": "license-rule-2", + "label": "license-rule-2-label", + "description": "Rule 2 description", + "type": "condition", + }, + { + "name": "license-rule-3", + "label": "license-rule-3-label", + "description": "Rule 3 description", + "type": "limitation", + }, + ], + ), + ], +) +def test_get_license_by_id( + client: TestClient, + license_id: str, + expected_is_spdx: bool, + expected_name: str, + expected_url: str, + expected_description: str, + expected_rules: list, +): + """GET /v1/licenses/{id} returns the expected license fields for known test licenses.""" + response = client.request("GET", f"/v1/licenses/{license_id}", headers=authHeaders) + assert response.status_code == 200 + body = response.json() + assert body["id"] == license_id + assert body.get("is_spdx") is expected_is_spdx + assert body.get("name") == expected_name + assert body.get("url") == expected_url + assert body.get("description") == expected_description + # Transform the license rules array into a dictionary so we don't have to worry about order. + actual_rules = {rule["name"]: rule for rule in body.get("license_rules", [])} + expected_rule_map = {rule["name"]: rule for rule in expected_rules} + assert actual_rules == expected_rule_map + + +def test_get_licenses_list_contains_test_licenses(client: TestClient): + """GET /v1/licenses returns a list that includes license-1 and license-2 from test data.""" + response = client.request("GET", "/v1/licenses", headers=authHeaders, params={"limit": 100}) + assert response.status_code == 200 + body = response.json() + assert isinstance(body, list) + ids = {item.get("id") for item in body} + assert "license-1" in ids + assert "license-2" in ids + # List endpoint returns the base license schema, so license_rules should not be present. + for item in body: + assert "license_rules" not in item + + +def test_license_with_no_rules_returns_empty_list(client: TestClient): + """GET /v1/licenses/{id} should return an empty list for license_rules when no rules exist.""" + response = client.request("GET", "/v1/licenses/license-3", headers=authHeaders) + assert response.status_code == 200 + body = response.json() + assert body["id"] == "license-3" + assert body.get("license_rules") == [] diff --git a/api/tests/unittest/test_feeds.py b/api/tests/unittest/test_feeds.py index 70cfb6dc3..3805cc2e4 100644 --- a/api/tests/unittest/test_feeds.py +++ b/api/tests/unittest/test_feeds.py @@ -130,7 +130,14 @@ def test_feed_get(client: TestClient, mocker): Unit test for get_feeds """ mock_filter = mocker.patch.object(FeedFilter, "filter") - mock_filter.return_value.filter.return_value.first.return_value = mock_feed + # FeedsApiImpl.get_feed() builds a query like + # filter().outerjoin().options().filter().first(); mimic that so FeedImpl.from_orm + # receives the actual Feed ORM instead of a MagicMock chain. + chain = Mock() + for method in ("filter", "outerjoin", "options", "order_by", "limit", "offset"): + getattr(chain, method).return_value = chain + chain.first.return_value = mock_feed + mock_filter.return_value = chain response = client.request( "GET", diff --git a/docs/DatabaseCatalogAPI.yaml b/docs/DatabaseCatalogAPI.yaml index 6432ece35..733deff03 100644 --- a/docs/DatabaseCatalogAPI.yaml +++ b/docs/DatabaseCatalogAPI.yaml @@ -4,7 +4,7 @@ info: title: Mobility Database Catalog description: | API for the Mobility Database Catalog. See [https://mobilitydatabase.org/](https://mobilitydatabase.org/). - + The Mobility Database API uses OAuth2 authentication. To initiate a successful API request, an access token must be included as a bearer token in the HTTP header. Access tokens are valid for one hour. To obtain an access token, you'll first need a refresh token, which is long-lived and does not expire. termsOfService: https://mobilitydatabase.org/terms-and-conditions @@ -35,6 +35,8 @@ tags: description: "Metadata about the API" - name: "beta" description: "Beta endpoints of the API." + - name: "licenses" + description: "Licenses of the Mobility Database" paths: /v1/feeds: @@ -52,7 +54,7 @@ paths: - $ref: "#/components/parameters/is_official_query_param" security: - - Authentication: [ ] + - Authentication: [] responses: 200: description: > @@ -157,7 +159,7 @@ paths: - $ref: "#/components/parameters/version_param" security: - - Authentication: [ ] + - Authentication: [] responses: 200: description: Successful pull of the GBFS feeds info. @@ -166,7 +168,6 @@ paths: schema: $ref: "#/components/schemas/GbfsFeeds" - /v1/gtfs_feeds/{id}: parameters: - $ref: "#/components/parameters/feed_id_path_param" @@ -241,7 +242,7 @@ paths: - $ref: "#/components/parameters/downloaded_before" security: - - Authentication: [] + - Authentication: [] responses: 200: description: Successful pull of the requested datasets. @@ -323,7 +324,7 @@ paths:
Search Executed: 'new' & york & 'transit'
- + operationId: searchFeeds tags: - "search" @@ -355,6 +356,45 @@ paths: items: $ref: "#/components/schemas/SearchFeedItemResult" + /v1/licenses: + get: + description: Get the list of all licenses in the DB. + tags: + - "licenses" + operationId: getLicenses + parameters: + - $ref: "#/components/parameters/limit_query_param_licenses_endpoint" + - $ref: "#/components/parameters/offset" + + security: + - Authentication: [ ] + responses: + 200: + description: Successful pull of the licenses info. + content: + application/json: + schema: + $ref: "#/components/schemas/Licenses" + + /v1/licenses/{id}: + parameters: + - $ref: "#/components/parameters/license_id_path_param" + get: + description: Get the specified license from the Mobility Database, including the license rules. + tags: + - "licenses" + operationId: getLicense + security: + - Authentication: [ ] + responses: + 200: + description: > + Successful pull of the license info for the provided ID. + content: + application/json: + schema: + $ref: "#/components/schemas/LicenseWithRules" + components: schemas: Redirect: @@ -383,8 +423,8 @@ components: - gtfs_rt - gbfs example: gtfs -# Have to put the enum inline because of a bug in openapi-generator -# $ref: "#/components/schemas/DataType" + # Have to put the enum inline because of a bug in openapi-generator + # $ref: "#/components/schemas/DataType" created_at: description: The date and time the feed was added to the database, in ISO 8601 date-time format. type: string @@ -414,8 +454,8 @@ components: discriminator: propertyName: data_type mapping: - gtfs: '#/components/schemas/GtfsFeed' - gtfs_rt: '#/components/schemas/GtfsRTFeed' + gtfs: "#/components/schemas/GtfsFeed" + gtfs_rt: "#/components/schemas/GtfsRTFeed" properties: status: description: > @@ -459,7 +499,7 @@ components: A list of related links for the feed. type: array items: - $ref: '#/components/schemas/FeedRelatedLink' + $ref: "#/components/schemas/FeedRelatedLink" FeedRelatedLink: type: object properties: @@ -537,17 +577,17 @@ components: example: https://www.citybikenyc.com/ versions: description: > - A list of GBFS versions that the feed supports. Each version is represented by its version number and a list of endpoints. + A list of GBFS versions that the feed supports. Each version is represented by its version number and a list of endpoints. type: array items: - $ref: "#/components/schemas/GbfsVersion" + $ref: "#/components/schemas/GbfsVersion" bounding_box: $ref: "#/components/schemas/BoundingBox" bounding_box_generated_at: description: The date and time the bounding box was generated, in ISO 8601 date-time format. type: string example: 2023-07-10T22:06:00Z - format: date-time + format: date-time GbfsVersion: type: object @@ -587,7 +627,7 @@ components: items: $ref: "#/components/schemas/GbfsEndpoint" latest_validation_report: - $ref: "#/components/schemas/GbfsValidationReport" + $ref: "#/components/schemas/GbfsValidationReport" GbfsValidationReport: type: object @@ -595,15 +635,15 @@ components: A validation report of the GBFS feed. properties: validated_at: - description: > - The date and time the GBFS feed was validated, in ISO 8601 date-time format. - type: string - example: 2023-07-10T22:06:00Z - format: date-time + description: > + The date and time the GBFS feed was validated, in ISO 8601 date-time format. + type: string + example: 2023-07-10T22:06:00Z + format: date-time total_error: - type: integer - example: 10 - minimum: 0 + type: integer + example: 10 + minimum: 0 report_summary_url: description: > The URL of the JSON report of the validation summary. @@ -611,10 +651,10 @@ components: format: url example: https://storage.googleapis.com/mobilitydata-datasets-prod/validation-reports/gbfs-1234-202402121801.json validator_version: - description: > - The version of the validator used to validate the GBFS feed. - type: string - example: 1.0.13 + description: > + The version of the validator used to validate the GBFS feed. + type: string + example: 1.0.13 GbfsEndpoint: type: object @@ -625,17 +665,17 @@ components: type: string example: system_information url: - description: > - The URL of the endpoint. This is the URL where the endpoint can be accessed. - type: string - format: url - example: https://gbfs.citibikenyc.com/gbfs/system_information.json + description: > + The URL of the endpoint. This is the URL where the endpoint can be accessed. + type: string + format: url + example: https://gbfs.citibikenyc.com/gbfs/system_information.json language: - description: > - The language of the endpoint. This is the language that the endpoint is available in for versions 2.3 - and prior. - type: string - example: en + description: > + The language of the endpoint. This is the language that the endpoint is available in for versions 2.3 + and prior. + type: string + example: en is_feature: description: > A boolean value indicating if the endpoint is a feature. A feature is defined as an optionnal endpoint. @@ -674,11 +714,10 @@ components: * vp - vehicle positions * tu - trip updates * sa - service alerts -# Have to put the enum inline because of a bug in openapi-generator -# $ref: "#/components/schemas/EntityTypes" + # Have to put the enum inline because of a bug in openapi-generator + # $ref: "#/components/schemas/EntityTypes" feed_references: - description: - A list of the GTFS feeds that the real time source is associated with, represented by their MDB source IDs. + description: A list of the GTFS feeds that the real time source is associated with, represented by their MDB source IDs. type: array items: type: string @@ -687,10 +726,10 @@ components: $ref: "#/components/schemas/Locations" SearchFeedItemResult: - # The following schema is used to represent the search results for feeds. - # The schema is a union of all the possible types(Feed, GtfsFeed, GtfsRTFeed and GbfsFeed) of feeds that can be returned. - # This union is not based on its original types due to the limitations of openapi-generator. - # For the same reason it's not defined as anyOf, but as a single object with all the possible properties. + # The following schema is used to represent the search results for feeds. + # The schema is a union of all the possible types(Feed, GtfsFeed, GtfsRTFeed and GbfsFeed) of feeds that can be returned. + # This union is not based on its original types due to the limitations of openapi-generator. + # For the same reason it's not defined as anyOf, but as a single object with all the possible properties. type: object required: - id @@ -708,8 +747,8 @@ components: - gtfs_rt - gbfs example: gtfs -# Have to put the enum inline because of a bug in openapi-generator -# $ref: "#/components/schemas/DataType" + # Have to put the enum inline because of a bug in openapi-generator + # $ref: "#/components/schemas/DataType" status: description: > Describes status of the Feed. Should be one of @@ -726,19 +765,19 @@ components: - development - future example: deprecated -# Have to put the enum inline because of a bug in openapi-generator -# $ref: "#/components/schemas/FeedStatus" + # Have to put the enum inline because of a bug in openapi-generator + # $ref: "#/components/schemas/FeedStatus" created_at: description: The date and time the feed was added to the database, in ISO 8601 date-time format. type: string example: 2023-07-10T22:06:00Z format: date-time official: - description: > - A boolean value indicating if the feed is official or not. - Official feeds are provided by the transit agency or a trusted source. - type: boolean - example: true + description: > + A boolean value indicating if the feed is official or not. + Official feeds are provided by the transit agency or a trusted source. + type: boolean + example: true external_ids: $ref: "#/components/schemas/ExternalIds" provider: @@ -752,8 +791,8 @@ components: type: string example: Bus note: - description: A note to clarify complex use cases for consumers. - type: string + description: A note to clarify complex use cases for consumers. + type: string feed_contact_email: description: Use to contact the feed producer. type: string @@ -782,8 +821,8 @@ components: * vp - vehicle positions * tu - trip updates * sa - service alerts -# Have to put the enum inline because of a bug in openapi-generator -# $ref: "#/components/schemas/EntityTypes" + # Have to put the enum inline because of a bug in openapi-generator + # $ref: "#/components/schemas/EntityTypes" versions: type: array items: @@ -791,8 +830,7 @@ components: example: 2.3 description: The supported versions of the GBFS feed. feed_references: - description: - A list of the GTFS feeds that the real time source is associated with, represented by their MDB source IDs. + description: A list of the GTFS feeds that the real time source is associated with, represented by their MDB source IDs. type: array items: type: string @@ -895,24 +933,24 @@ components: example: 3 minimum: 0 -# Have to put the enum inline because of a bug in openapi-generator -# EntityTypes: -# type: array -# items: -# $ref: "#/components/schemas/EntityType" - -# EntityType: -# type: string -# enum: -# - vp -# - tu -# - sa -# example: vp -# description: > -# The type of realtime entry: -# * vp - vehicle positions -# * tu - trip updates -# * sa - service alerts + # Have to put the enum inline because of a bug in openapi-generator + # EntityTypes: + # type: array + # items: + # $ref: "#/components/schemas/EntityType" + + # EntityType: + # type: string + # enum: + # - vp + # - tu + # - sa + # example: vp + # description: > + # The type of realtime entry: + # * vp - vehicle positions + # * tu - trip updates + # * sa - service alerts ExternalIds: type: array @@ -972,6 +1010,18 @@ components: type: string format: url example: https://www.ladottransit.com/dla.html + license_id: + description: Id of the feed license that can be used to query the license endpoint. + type: string + example: 0BSD + license_is_spdx: + description: true if the license is SPDX. false if not. + type: boolean + example: true + license_notes: + description: Notes concerning the relation between the feed and the license. + type: string + example: Detected locale/jurisdiction port 'nl'. SPDX does not list ported CC licenses; using canonical ID. Locations: type: array @@ -988,9 +1038,9 @@ components: type: string example: US country: - description: The english name of the country where the system is located. - type: string - example: United States + description: The english name of the country where the system is located. + type: string + example: United States subdivision_name: description: > ISO 3166-2 english subdivision name designating the subdivision (e.g province, state, region) where the system is located. @@ -1002,23 +1052,23 @@ components: type: string example: Los Angeles -# Have to put the enum inline because of a bug in openapi-generator -# FeedStatus: -# description: > -# Describes status of the Feed. Should be one of -# * `active` Feed should be used in public trip planners. -# * `deprecated` Feed is explicitly deprecated and should not be used in public trip planners. -# * `inactive` Feed hasn't been recently updated and should be used at risk of providing outdated information. -# * `development` Feed is being used for development purposes and should not be used in public trip planners. -# * `future` Feed is not yet active but will be in the future -# type: string -# enum: -# - active -# - deprecated -# - inactive -# - development -# - future -# example: active + # Have to put the enum inline because of a bug in openapi-generator + # FeedStatus: + # description: > + # Describes status of the Feed. Should be one of + # * `active` Feed should be used in public trip planners. + # * `deprecated` Feed is explicitly deprecated and should not be used in public trip planners. + # * `inactive` Feed hasn't been recently updated and should be used at risk of providing outdated information. + # * `development` Feed is being used for development purposes and should not be used in public trip planners. + # * `future` Feed is not yet active but will be in the future + # type: string + # enum: + # - active + # - deprecated + # - inactive + # - development + # - future + # example: active BasicDataset: type: object @@ -1168,20 +1218,85 @@ components: format: url description: HTML validation report URL example: https://storage.googleapis.com/mobilitydata-datasets-dev/mdb-10/mdb-10-202312181718/mdb-10-202312181718-report-4_2_0.html -# url_system_errors: -# type: string -# format: url -# description: JSON validation system errors URL -# example: https://storage.googleapis.com/mobilitydata-datasets-dev/mdb-10/mdb-10-202312181718/mdb-10-202312181718-system-errors-4_2_0.json - -# Have to put the enum inline because of a bug in openapi-generator -# DataType: -# type: string -# enum: -# - gtfs -# - gtfs_rt -# - gbfs -# example: gtfs + + LicenseRule: + type: object + properties: + name: + description: Name of the rule. + type: string + example: commercial-use + label: + description: Label of the rule. + type: string + example: Commercial use + description: + description: Description of the rule. + type: string + example: This license allows the software or data to be used for commercial purposes. + type: + description: Type of rule. + type: string + enum: + - permission + - condition + - limitation + + LicenseBase: + type: object + properties: + id: + description: Unique identifier for the license. + type: string + example: 0BSD + type: + type: string + description: The type of license. + example: standard + is_spdx: + type: boolean + description: true if license id spdx. + name: + type: string + description: The user facing name of the license. + example: BSD Zero Clause License + url: + description: A URL where to find the license for the feed. + type: string + format: url + example: https://www.ladottransit.com/dla.html + description: + type: string + description: The description of the license. + example: This is the OBSD license. + created_at: + description: The date and time the license was added to the database, in ISO 8601 date-time format. + type: string + example: 2023-07-10T22:06:00Z + format: date-time + updated_at: + description: The last date and time the license was updated in the database, in ISO 8601 date-time format. + type: string + example: 2023-07-10T22:06:00Z + format: date-time + + LicenseWithRules: + allOf: + - $ref: "#/components/schemas/LicenseBase" + - type: object + properties: + license_rules: + type: array + items: + $ref: "#/components/schemas/LicenseRule" + + + Licenses: + type: array + items: + $ref: "#/components/schemas/LicenseBase" + + parameters: status: name: status @@ -1197,7 +1312,7 @@ components: - development - future statuses: - # This parameter name is kept as status to maintain backward compatibility. + # This parameter name is kept as status to maintain backward compatibility. name: status in: query description: Filter feeds by their status. [Status definitions defined here](https://github.com/MobilityData/mobility-database-catalogs?tab=readme-ov-file#gtfs-schedule-schema) @@ -1293,9 +1408,9 @@ components: name: dataset_latitudes in: query description: > - Specify the minimum and maximum latitudes of the bounding box to use for filtering. -
Filters by the bounding box of the `LatestDataset` for a feed. -
Must be specified alongside `dataset_longitudes`. + Specify the minimum and maximum latitudes of the bounding box to use for filtering. +
Filters by the bounding box of the `LatestDataset` for a feed. +
Must be specified alongside `dataset_longitudes`. required: False schema: type: string @@ -1420,7 +1535,19 @@ components: maximum: 500 default: 500 example: 10 - + + limit_query_param_licenses_endpoint: + name: limit + in: query + description: The number of items to be returned. + required: False + schema: + type: integer + minimum: 0 + maximum: 100 + default: 100 + example: 10 + offset: name: offset in: query @@ -1476,6 +1603,15 @@ components: type: string example: mdb-1210 + license_id_path_param: + name: id + in: path + description: The license ID of the requested license. + required: True + schema: + type: string + example: 0BSD + feed_id_of_datasets_path_param: name: id in: path @@ -1512,7 +1648,6 @@ components: type: string example: 2.3 - securitySchemes: Authentication: $ref: "./BearerTokenSchema.yaml#/components/securitySchemes/Authentication" diff --git a/web-app/src/app/services/feeds/types.ts b/web-app/src/app/services/feeds/types.ts index 2ba79d800..7ad17a75d 100644 --- a/web-app/src/app/services/feeds/types.ts +++ b/web-app/src/app/services/feeds/types.ts @@ -105,6 +105,19 @@ export interface paths { */ get: operations['searchFeeds']; }; + '/v1/licenses': { + /** @description Get the list of all licenses in the DB. */ + get: operations['getLicenses']; + }; + '/v1/licenses/{id}': { + /** @description Get the specified license from the Mobility Database, including the license rules. */ + get: operations['getLicense']; + parameters: { + path: { + id: components['parameters']['license_id_path_param']; + }; + }; + }; } export type webhooks = Record; @@ -554,6 +567,21 @@ export interface components { * @example https://www.ladottransit.com/dla.html */ license_url?: string; + /** + * @description Id of the feed license that can be used to query the license endpoint. + * @example 0BSD + */ + license_id?: string; + /** + * @description true if the license is SPDX. false if not. + * @example true + */ + license_is_spdx?: boolean; + /** + * @description Notes concerning the relation between the feed and the license. + * @example Detected locale/jurisdiction port 'nl'. SPDX does not list ported CC licenses; using canonical ID. + */ + license_notes?: string; }; Locations: Array; Location: { @@ -715,6 +743,74 @@ export interface components { */ url_html?: string; }; + LicenseRule: { + /** + * @description Name of the rule. + * @example commercial-use + */ + name?: string; + /** + * @description Label of the rule. + * @example Commercial use + */ + label?: string; + /** + * @description Description of the rule. + * @example This license allows the software or data to be used for commercial purposes. + */ + description?: string; + /** + * @description Type of rule. + * @enum {string} + */ + type?: 'permission' | 'condition' | 'limitation'; + }; + LicenseBase: { + /** + * @description Unique identifier for the license. + * @example 0BSD + */ + id?: string; + /** + * @description The type of license. + * @example standard + */ + type?: string; + /** @description true if license id spdx. */ + is_spdx?: boolean; + /** + * @description The user facing name of the license. + * @example BSD Zero Clause License + */ + name?: string; + /** + * Format: url + * @description A URL where to find the license for the feed. + * @example https://www.ladottransit.com/dla.html + */ + url?: string; + /** + * @description The description of the license. + * @example This is the OBSD license. + */ + description?: string; + /** + * Format: date-time + * @description The date and time the license was added to the database, in ISO 8601 date-time format. + * @example "2023-07-10T22:06:00.000Z" + */ + created_at?: string; + /** + * Format: date-time + * @description The last date and time the license was updated in the database, in ISO 8601 date-time format. + * @example "2023-07-10T22:06:00.000Z" + */ + updated_at?: string; + }; + LicenseWithRules: components['schemas']['LicenseBase'] & { + license_rules?: Array; + }; + Licenses: Array; }; responses: never; parameters: { @@ -778,6 +874,8 @@ export interface components { limit_query_param_search_endpoint?: number; /** @description The number of items to be returned. */ limit_query_param_gbfs_feeds_endpoint?: number; + /** @description The number of items to be returned. */ + limit_query_param_licenses_endpoint?: number; /** @description Offset of the first item to return. */ offset?: number; /** @description General search query to match against transit provider, location, and feed name. */ @@ -790,6 +888,8 @@ export interface components { feed_id_query_param?: string; /** @description The feed ID of the requested feed. */ feed_id_path_param: string; + /** @description The license ID of the requested license. */ + license_id_path_param: string; /** @description The ID of the feed for which to obtain datasets. */ feed_id_of_datasets_path_param: string; /** @description The ID of the requested dataset. */ @@ -1095,4 +1195,37 @@ export interface operations { }; }; }; + /** @description Get the list of all licenses in the DB. */ + getLicenses: { + parameters: { + query?: { + limit?: components['parameters']['limit_query_param_licenses_endpoint']; + offset?: components['parameters']['offset']; + }; + }; + responses: { + /** @description Successful pull of the licenses info. */ + 200: { + content: { + 'application/json': components['schemas']['Licenses']; + }; + }; + }; + }; + /** @description Get the specified license from the Mobility Database, including the license rules. */ + getLicense: { + parameters: { + path: { + id: components['parameters']['license_id_path_param']; + }; + }; + responses: { + /** @description Successful pull of the license info for the provided ID. */ + 200: { + content: { + 'application/json': components['schemas']['LicenseWithRules']; + }; + }; + }; + }; }