diff --git a/api/src/shared/db_models/feed_impl.py b/api/src/shared/db_models/feed_impl.py index bfdd67a4b..26c613965 100644 --- a/api/src/shared/db_models/feed_impl.py +++ b/api/src/shared/db_models/feed_impl.py @@ -1,6 +1,11 @@ +import logging + +from sqlalchemy.orm import Session + +from shared.database.database import with_db_session from shared.db_models.basic_feed_impl import BaseFeedImpl from feeds_gen.models.feed import Feed -from shared.database_gen.sqlacodegen_models import Feed as FeedOrm +from shared.database_gen.sqlacodegen_models import Feed as FeedOrm, License from shared.db_models.external_id_impl import ExternalIdImpl from shared.db_models.feed_related_link_impl import FeedRelatedLinkImpl @@ -33,10 +38,18 @@ def from_orm(cls, feed_orm: FeedOrm | None) -> Feed | None: return feed @classmethod - def to_orm_from_dict(cls, feed_dict: dict | None) -> FeedOrm | None: + @with_db_session + def to_orm_from_dict(cls, feed_dict: dict | None, db_session: Session | None = None) -> FeedOrm | None: """Convert a dictionary representation of a feed to a SQLAlchemy Feed ORM object.""" if not feed_dict: return None + license_id = None + if feed_dict.get("license_id") is not None: + license_orm = db_session.query(License).get(feed_dict["license_id"]) + if not license_orm: + logging.warning("License with id %s not found.", feed_dict["license_id"]) + license_id = license_orm.id if license_orm else None + result: Feed = FeedOrm( id=feed_dict.get("id"), stable_id=feed_dict.get("stable_id"), @@ -51,6 +64,8 @@ def to_orm_from_dict(cls, feed_dict: dict | None) -> FeedOrm | None: authentication_info_url=feed_dict.get("authentication_info_url"), api_key_parameter_name=feed_dict.get("api_key_parameter_name"), license_url=feed_dict.get("license_url"), + license_id=license_id, + license_notes=feed_dict.get("license_notes"), status=feed_dict.get("status"), official=feed_dict.get("official"), official_updated_at=feed_dict.get("official_updated_at"), diff --git a/docs/OperationsAPI.yaml b/docs/OperationsAPI.yaml index e77ade116..e6190a40c 100644 --- a/docs/OperationsAPI.yaml +++ b/docs/OperationsAPI.yaml @@ -17,6 +17,8 @@ info: tags: - name: "operations" description: "Mobility Database Operations" + - name: "licenses" + description: "Licenses of the Mobility Database" paths: /v1/operations/feeds: get: @@ -226,7 +228,7 @@ paths: A feed with the producer_url already exists. 500: - description: "An internal server error occurred. \n" + description: "An internal server error occurred. \n" put: description: Update the specified GTFS-RT feed in the Mobility Database. tags: @@ -262,6 +264,45 @@ paths: description: > An internal server error occurred. + /v1/operations/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" + - $ref: "#/components/parameters/search_text_query_param_license" + - $ref: "#/components/parameters/license_is_spdx_query_param" + security: + - Authentication: [] + responses: + 200: + description: Successful pull of the licenses info. + content: + application/json: + schema: + $ref: "#/components/schemas/Licenses" + /v1/operations/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: @@ -289,8 +330,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 @@ -319,8 +360,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: > @@ -367,7 +408,7 @@ components: type: array items: - $ref: '#/components/schemas/FeedRelatedLink' + $ref: "#/components/schemas/FeedRelatedLink" FeedRelatedLink: type: object properties: @@ -595,8 +636,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" feed_references: description: A list of the GTFS feeds that the real time source is associated with, represented by their MDB source IDs. type: array @@ -627,8 +668,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 @@ -646,8 +687,8 @@ 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 @@ -703,8 +744,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: @@ -809,24 +850,24 @@ components: type: integer 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 items: @@ -885,6 +926,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 items: @@ -912,23 +965,23 @@ components: description: Primary municipality in english in which the transit system is located. 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 properties: @@ -1072,6 +1125,78 @@ 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 + 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 the license ID is an SPDX identifier. + 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" OperationCreateRequestGtfsFeed: x-operation: true type: object @@ -1142,6 +1267,7 @@ components: feed_name: description: > An optional description of the data feed, e.g to specify if the data feed is an aggregate of multiple providers, or which network is represented by the feed. + type: string example: Bus note: @@ -1181,6 +1307,8 @@ components: example: vp description: > The type of realtime entry: + + * vp - vehicle positions * tu - trip updates * sa - service alerts @@ -1195,13 +1323,14 @@ components: related_links: description: > A list of related links for the feed. + type: array items: - $ref: '#/components/schemas/FeedRelatedLink' + $ref: '#/components/schemas/FeedRelatedLink' required: - source_info - operational_status - - entity_types + - entity_types OperationFeed: x-operation: true allOf: @@ -1298,6 +1427,8 @@ components: example: vp description: > The type of realtime entry: + + * vp - vehicle positions * tu - trip updates * sa - service alerts @@ -1373,6 +1504,8 @@ components: x-operation: true 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. @@ -1390,6 +1523,8 @@ components: x-operation: true description: > Describes data type of a feed. Should be one of + + * `gtfs` GTFS feed. * `gtfs_rt` GTFS-RT feed. * `gbfs` GBFS feed. @@ -1409,6 +1544,55 @@ components: schema: type: string example: mdb-1210 + license_id_path_param: + x-operation: true + name: id + in: path + description: The license ID of the requested license. + required: True + schema: + type: string + example: 0BSD + license_is_spdx_query_param: + x-operation: true + name: is_spdx + in: query + description: True if the license ID is SPDX. + required: False + schema: + type: boolean + example: true + limit_query_param_licenses_endpoint: + x-operation: true + 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 + search_text_query_param_license: + x-operation: true + name: search_query + in: query + description: General search query to match against license name and ID + required: False + schema: + type: string + offset: + x-operation: true + name: offset + in: query + description: Offset of the first item to return. + required: False + schema: + type: integer + minimum: 0 + default: 0 + example: 0 securitySchemes: ApiKeyAuth: type: apiKey diff --git a/functions-python/operations_api/.openapi-generator/FILES b/functions-python/operations_api/.openapi-generator/FILES index b785b0b1f..c0587d7b0 100644 --- a/functions-python/operations_api/.openapi-generator/FILES +++ b/functions-python/operations_api/.openapi-generator/FILES @@ -1,4 +1,6 @@ src/feeds_gen/apis/__init__.py +src/feeds_gen/apis/licenses_api.py +src/feeds_gen/apis/licenses_api_base.py src/feeds_gen/apis/operations_api.py src/feeds_gen/apis/operations_api_base.py src/feeds_gen/main.py @@ -22,6 +24,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/operation_create_request_gtfs_feed.py diff --git a/functions-python/operations_api/src/feeds_operations/impl/feeds_operations_impl.py b/functions-python/operations_api/src/feeds_operations/impl/feeds_operations_impl.py index 9ded741bd..79e838c23 100644 --- a/functions-python/operations_api/src/feeds_operations/impl/feeds_operations_impl.py +++ b/functions-python/operations_api/src/feeds_operations/impl/feeds_operations_impl.py @@ -368,10 +368,22 @@ async def create_gtfs_feed( db_session.add(new_feed) db_session.commit() created_feed = db_session.get(Gtfsfeed, new_feed.id) - trigger_dataset_download( - created_feed, - get_execution_id(get_request_context(), "feed-created-process"), - ) + if created_feed is None: + raise HTTPException( + status_code=500, + detail=f"Failed to create GTFS feed with URL: {new_feed.producer_url}", + ) + try: + trigger_dataset_download( + created_feed, + get_execution_id(get_request_context(), "feed-created-process"), + ) + except Exception as exc: + logging.error( + "Failed to trigger dataset download for feed ID: %s. Error: %s", + created_feed.stable_id, + exc, + ) logging.info("Created new GTFS feed with ID: %s", new_feed.stable_id) refreshed = refresh_materialized_view(db_session, t_feedsearch.name) logging.info("Materialized view %s refreshed: %s", t_feedsearch.name, refreshed) diff --git a/functions-python/operations_api/src/feeds_operations/impl/licenses_api_impl.py b/functions-python/operations_api/src/feeds_operations/impl/licenses_api_impl.py new file mode 100644 index 000000000..557cde386 --- /dev/null +++ b/functions-python/operations_api/src/feeds_operations/impl/licenses_api_impl.py @@ -0,0 +1,109 @@ +# +# MobilityData 2025 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +from typing import List, Optional + +from fastapi import HTTPException +from pydantic import StrictStr +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from feeds_gen.apis.licenses_api_base import BaseLicensesApi +from feeds_gen.models.license_base import LicenseBase +from feeds_gen.models.license_with_rules import LicenseWithRules +from shared.database.database import with_db_session +from shared.database_gen.sqlacodegen_models import License as OrmLicense +from shared.db_models.license_base_impl import LicenseBaseImpl +from shared.db_models.license_with_rules_impl import LicenseWithRulesImpl + + +class LicensesApiImpl(BaseLicensesApi): + """Implementation of the Licenses API. + + This class provides concrete implementations for the generated + `BaseLicensesApi` methods, mirroring the style used in + `OperationsApiImpl` and relying on the shared SQLAlchemy models. + """ + + @with_db_session + async def get_license( + self, + id: StrictStr, + db_session: Session = None, + ) -> LicenseWithRules: + """Get the specified license from the Mobility Database. + + Raises 404 if the license is not found. + """ + logging.info("Fetching license with id: %s", id) + + license_orm: Optional[OrmLicense] = db_session.get(OrmLicense, id) + if license_orm is None: + logging.warning("License not found: %s", id) + raise HTTPException(status_code=404, detail="License not found") + + # Build Pydantic model from ORM object attributes + return LicenseWithRulesImpl.from_orm(license_orm) + + @with_db_session + async def get_licenses( + self, + limit: int, + offset: int, + search_query: Optional[StrictStr] = None, + is_spdx: Optional[bool] = None, + db_session: Session = None, + ) -> List[LicenseBase]: + """Get the list of licenses from the Mobility Database. + + Supports pagination via `limit` and `offset`, optional + case-insensitive text search on license name / id, and + optional filtering by SPDX status. + """ + + logging.info( + "Fetching licenses with limit=%s offset=%s search_query=%s is_spdx=%s", + limit, + offset, + search_query, + is_spdx, + ) + + query = db_session.query(OrmLicense) + + # Text search by name or id + if search_query and search_query.strip(): + pattern = f"%{search_query.strip()}%" + try: + conditions = [ + func.lower(OrmLicense.name).like(func.lower(pattern)), + func.lower(OrmLicense.id).like(func.lower(pattern)), + ] + query = query.filter(or_(*conditions)) + except Exception as exc: # defensive; shouldn't normally trigger + logging.error("Failed applying search filter: %s", exc) + + # Optional SPDX filter + if is_spdx is not None: + query = query.filter(OrmLicense.is_spdx == is_spdx) + + query = query.order_by(OrmLicense.id).offset(offset).limit(limit) + items: List[OrmLicense] = query.all() + + logging.info("Fetched %d licenses", len(items)) + + return [LicenseBaseImpl.from_orm(item) for item in items] diff --git a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py index 5ee106237..94944ea53 100644 --- a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py +++ b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_feed_impl.py @@ -58,6 +58,9 @@ def from_orm(cls, obj: Gtfsfeed | None) -> UpdateRequestGtfsFeed | None: authentication_info_url=obj.authentication_info_url, api_key_parameter_name=obj.api_key_parameter_name, license_url=obj.license_url, + license_id=obj.license_id, + license_notes=obj.license_notes, + license_is_spdx=obj.license.is_spdx if obj.license else None, ), redirects=sorted( [RedirectImpl.from_orm(item) for item in obj.redirectingids], @@ -123,7 +126,22 @@ def to_orm( ) else update_request.source_info.license_url ) - + entity.license_id = ( + None + if ( + update_request.source_info is None + or update_request.source_info.license_id is None + ) + else update_request.source_info.license_id + ) + entity.license_notes = ( + None + if ( + update_request.source_info is None + or update_request.source_info.license_notes is None + ) + else update_request.source_info.license_notes + ) redirecting_ids = ( [] if update_request.redirects is None diff --git a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py index 373991e07..0a9de6b9e 100644 --- a/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py +++ b/functions-python/operations_api/src/feeds_operations/impl/models/update_request_gtfs_rt_feed_impl.py @@ -64,6 +64,9 @@ def from_orm(cls, obj: Gtfsrealtimefeed | None) -> UpdateRequestGtfsRtFeed | Non authentication_info_url=obj.authentication_info_url, api_key_parameter_name=obj.api_key_parameter_name, license_url=obj.license_url, + license_id=obj.license_id, + license_notes=obj.license_notes, + license_is_spdx=obj.license.is_spdx if obj.license else None, ), redirects=sorted( [RedirectImpl.from_orm(item) for item in obj.redirectingids], diff --git a/functions-python/operations_api/src/main.py b/functions-python/operations_api/src/main.py index 7a24f2eec..ebe1bf498 100644 --- a/functions-python/operations_api/src/main.py +++ b/functions-python/operations_api/src/main.py @@ -17,6 +17,7 @@ from flask import Request, Response from fastapi import FastAPI from feeds_gen.apis.operations_api import router as FeedsApiRouter +from feeds_gen.apis.licenses_api import router as LicenseApiRouter import functions_framework import asyncio @@ -34,6 +35,7 @@ # Add here middlewares that should be applied to all routes. app.add_middleware(RequestContextMiddleware) app.include_router(FeedsApiRouter) +app.include_router(LicenseApiRouter) def build_scope_from_wsgi(request: Request) -> dict: diff --git a/functions-python/operations_api/tests/conftest.py b/functions-python/operations_api/tests/conftest.py index 2e5956e5f..2829887de 100644 --- a/functions-python/operations_api/tests/conftest.py +++ b/functions-python/operations_api/tests/conftest.py @@ -20,6 +20,8 @@ Gtfsfeed, Gtfsrealtimefeed, Entitytype, + License, + Rule, ) from test_shared.test_utils.database_utils import clean_testing_db, default_db_url @@ -50,6 +52,7 @@ authentication_info_url="authentication_info_url", api_key_parameter_name="api_key_parameter_name", license_url="license_url", + license_id="MIT", stable_id="mdb-40", status="active", feed_contact_email="feed_contact_email", @@ -75,6 +78,44 @@ gtfs_rt_feeds=[], ) +# Test license objects used by LicensesApiImpl tests +license_std_mit = License( + id="MIT", + type="standard", + is_spdx=True, + name="MIT License", + url="https://opensource.org/licenses/MIT", + description="A short and permissive license.", +) + +license_custom_test = License( + id="custom-test", + type="custom", + is_spdx=False, + name="Custom Test License", + url="https://example.com/custom-test-license", + description="Custom license used for testing.", +) + +# Test rules associated to licenses +rule_attribution = Rule( + name="attribution", + label="Attribution required", + type="condition", + description="Must attribute the data source when using the data.", +) + +rule_share_alike = Rule( + name="share-alike", + label="Share alike", + type="condition", + description="Derivative works must be shared under the same terms.", +) + +# Attach rules to licenses so LicenseWithRules has content +license_std_mit.rules = [rule_attribution] +license_custom_test.rules = [rule_attribution, rule_share_alike] + @with_db_session(db_url=default_db_url) def populate_database(db_session): @@ -84,9 +125,12 @@ def populate_database(db_session): - 1 GTFS Realtime feeds """ db_session.add(feed_mdb_41) - # session.flush() db_session.add(feed_mdb_40) db_session.add(feed_mdb_400) + db_session.add(rule_attribution) + db_session.add(rule_share_alike) + db_session.add(license_std_mit) + db_session.add(license_custom_test) db_session.commit() diff --git a/functions-python/operations_api/tests/feeds_operations/impl/test_feeds_operations_impl_gtfs.py b/functions-python/operations_api/tests/feeds_operations/impl/test_feeds_operations_impl_gtfs.py index 33fa202aa..cb8e24818 100644 --- a/functions-python/operations_api/tests/feeds_operations/impl/test_feeds_operations_impl_gtfs.py +++ b/functions-python/operations_api/tests/feeds_operations/impl/test_feeds_operations_impl_gtfs.py @@ -29,6 +29,9 @@ def update_request_gtfs_feed(): authentication_info_url=feed_mdb_40.authentication_info_url, api_key_parameter_name=feed_mdb_40.api_key_parameter_name, license_url=feed_mdb_40.license_url, + license_id=feed_mdb_40.license_id, + license_notes=feed_mdb_40.license_notes, + license_is_spdx=True, ), redirects=[], operational_status_action="no_change", diff --git a/functions-python/operations_api/tests/feeds_operations/impl/test_licenses_api_impl.py b/functions-python/operations_api/tests/feeds_operations/impl/test_licenses_api_impl.py new file mode 100644 index 000000000..42415776b --- /dev/null +++ b/functions-python/operations_api/tests/feeds_operations/impl/test_licenses_api_impl.py @@ -0,0 +1,159 @@ +import pytest +from fastapi import HTTPException + +from feeds_operations.impl.licenses_api_impl import LicensesApiImpl +from feeds_gen.models.license_with_rules import LicenseWithRules +from feeds_gen.models.license_base import LicenseBase +from shared.database.database import Database +from shared.database_gen.sqlacodegen_models import License as OrmLicense +from test_shared.test_utils.database_utils import default_db_url + + +@pytest.fixture +def db_session(): + db = Database(feeds_database_url=default_db_url) + with db.start_db_session() as session: + yield session + + +@pytest.mark.asyncio +async def test_get_license_success(db_session): + # Arrange: pick an existing license id from the database + existing = db_session.query(OrmLicense).first() + if existing is None: + pytest.skip("No licenses in test database to validate get_license") + + api = LicensesApiImpl() + + # Act + result = await api.get_license(existing.id) + + # Assert + assert isinstance(result, LicenseWithRules) + assert result.id == existing.id + assert result.name == existing.name + + +@pytest.mark.asyncio +async def test_get_license_not_found(): + api = LicensesApiImpl() + + with pytest.raises(HTTPException) as exc_info: + await api.get_license("non-existent-license-id") + + assert exc_info.value.status_code == 404 + assert exc_info.value.detail == "License not found" + + +@pytest.mark.asyncio +async def test_get_licenses_basic_pagination(db_session): + # Ensure there is at least one license; otherwise, skip. + count = db_session.query(OrmLicense).count() + if count == 0: + pytest.skip("No licenses in test database to validate get_licenses") + + api = LicensesApiImpl() + + # Act + result = await api.get_licenses(limit=10, offset=0) + + # Assert + assert isinstance(result, list) + assert all(isinstance(item, LicenseBase) for item in result) + # We don't assert exact size because DB contents may vary, but + # if there are licenses in DB, result should not be empty. + assert len(result) > 0 + + +@pytest.mark.asyncio +async def test_get_licenses_with_search_query(db_session): + # Take a license from the DB and use part of its name as a search query + existing = db_session.query(OrmLicense).first() + if existing is None or not existing.name: + pytest.skip("No suitable license data in DB to test search_query") + + fragment = existing.name[:3] + api = LicensesApiImpl() + + result = await api.get_licenses(limit=50, offset=0, search_query=fragment) + + assert isinstance(result, list) + # True positive: the reference license must be present + assert any(item.id == existing.id for item in result) + + # False positives: every returned item must match the search criteria + # (assuming search is done against name, adapt if implementation differs) + lowered_fragment = fragment.lower() + assert all( + (item.name or "").lower().find(lowered_fragment) != -1 for item in result + ), "Search results contain licenses whose names do not match the search fragment" + + +@pytest.mark.asyncio +async def test_get_licenses_filter_is_spdx_true(db_session): + # There should be at least one SPDX license from test data + spdx_count = ( + db_session.query(OrmLicense).filter(OrmLicense.is_spdx.is_(True)).count() + ) + if spdx_count == 0: + pytest.skip("No SPDX licenses in test database to validate is_spdx filter") + + api = LicensesApiImpl() + result = await api.get_licenses(limit=10, offset=0, is_spdx=True) + + assert isinstance(result, list) + assert len(result) == spdx_count + assert all(item.is_spdx is True for item in result) + + +@pytest.mark.asyncio +async def test_get_licenses_filter_is_spdx_false(db_session): + # There should be at least one non-SPDX license from test data + non_spdx_count = ( + db_session.query(OrmLicense).filter(OrmLicense.is_spdx.is_(False)).count() + ) + if non_spdx_count == 0: + pytest.skip("No non-SPDX licenses in test database to validate is_spdx filter") + + api = LicensesApiImpl() + result = await api.get_licenses(limit=10, offset=0, is_spdx=False) + + assert isinstance(result, list) + assert len(result) == non_spdx_count + assert all(item.is_spdx is False for item in result) + + +@pytest.mark.asyncio +async def test_get_license_includes_rules(db_session): + """Ensure that get_license returns associated rules in LicenseWithRules.""" + existing = ( + db_session.query(OrmLicense) + .filter(OrmLicense.id == "custom-test") + .one_or_none() + ) + if existing is None: + pytest.skip("Fixture license 'custom-test' not found in test database") + + api = LicensesApiImpl() + result = await api.get_license("custom-test") + + assert isinstance(result, LicenseWithRules) + # LicenseWithRules exposes rules under the license_rules field + assert result.license_rules is not None + rule_names = sorted(rule.name for rule in result.license_rules) + assert rule_names == ["attribution", "share-alike"] + + +@pytest.mark.asyncio +async def test_get_licenses_includes_rules_for_each_item(db_session): + """Ensure that licenses returned by get_licenses can expose rule data via get_license.""" + api = LicensesApiImpl() + results = await api.get_licenses(limit=10, offset=0) + + assert results + for item in results: + if item.id == "MIT": + detailed = await api.get_license("MIT") + assert isinstance(detailed, LicenseWithRules) + assert detailed.license_rules is not None + assert [r.name for r in detailed.license_rules] == ["attribution"] diff --git a/liquibase/changelog.xml b/liquibase/changelog.xml index 79c9f1201..5e7738113 100644 --- a/liquibase/changelog.xml +++ b/liquibase/changelog.xml @@ -85,5 +85,8 @@ - + + + + diff --git a/liquibase/changes/feat_1432_1.sql b/liquibase/changes/feat_1432_1.sql new file mode 100644 index 000000000..cdd051cff --- /dev/null +++ b/liquibase/changes/feat_1432_1.sql @@ -0,0 +1,4 @@ +-- Create indexes to enhance SQL queries running like commands against license id and name + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_license_lower_id + ON license ((lower(id))); diff --git a/liquibase/changes/feat_1432_2.sql b/liquibase/changes/feat_1432_2.sql new file mode 100644 index 000000000..86d6762f4 --- /dev/null +++ b/liquibase/changes/feat_1432_2.sql @@ -0,0 +1,4 @@ +-- Create indexes to enhance SQL queries running like commands against license id and name + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_license_lower_name + ON license ((lower(name))); diff --git a/liquibase/changes/feat_1432_3.sql b/liquibase/changes/feat_1432_3.sql new file mode 100644 index 000000000..75b45d5b5 --- /dev/null +++ b/liquibase/changes/feat_1432_3.sql @@ -0,0 +1,5 @@ +-- Create indexes to enhance SQL queries running like commands against license id and name +-- This changeset is executed after enabling the pg_trgm extension in a different transaction + +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_license_lower_name_trgm + ON license USING gin (lower(name) gin_trgm_ops); \ No newline at end of file diff --git a/liquibase/changes/feat_1432_indexes.xml b/liquibase/changes/feat_1432_indexes.xml new file mode 100644 index 000000000..d35ee79f5 --- /dev/null +++ b/liquibase/changes/feat_1432_indexes.xml @@ -0,0 +1,25 @@ + + + + + CREATE EXTENSION IF NOT EXISTS pg_trgm; + + + + + + + + + + + + + + + + \ No newline at end of file