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'];
+ };
+ };
+ };
+ };
}