Repository: Materials-Consortia/optimade-python-tools (python)

Issue:
Support for specifying database licenses
Now that https://github.com/Materials-Consortia/OPTIMADE/pull/414 is merged, we should add support for specifying a database license, with some thought about how we should increment `__api_version__` whilst supporting these as of yet unreleased features.

Pull request description:
Add configurable `license` and `available_licenses` info fields
Closes #1254. Duplicate of #1423 which has to be remerged due to a failed release.

To-do:

- [x] Add to models
- [x] Add to OpenAPI
- [x] Add to config and server response
- [x] Testing
- [x] Documentation

Patch:
diff --git a/openapi/index_openapi.json b/openapi/index_openapi.json
index a1e48112..858d1415 100644
--- a/openapi/index_openapi.json
+++ b/openapi/index_openapi.json
@@ -3,7 +3,7 @@
   "info": {
     "title": "OPTIMADE API - Index meta-database",
     "description": "The [Open Databases Integration for Materials Design (OPTIMADE) consortium](https://www.optimade.org/) aims to make materials databases interoperational by developing a common REST API.\nThis is the \"special\" index meta-database.\n\nThis specification is generated using [`optimade-python-tools`](https://github.com/Materials-Consortia/optimade-python-tools/tree/v1.1.11) v1.1.11.",
-    "version": "1.1.0"
+    "version": "1.2.0"
   },
   "paths": {
     "/info": {
@@ -1010,6 +1010,53 @@
             "title": "Is Index",
             "description": "This must be `true` since this is an index meta-database (see section Index Meta-Database).",
             "default": true
+          },
+          "license": {
+            "anyOf": [
+              {
+                "$ref": "#/components/schemas/Link"
+              },
+              {
+                "type": "string",
+                "minLength": 1,
+                "format": "uri"
+              },
+              {
+                "type": "null"
+              }
+            ],
+            "title": "License",
+            "description": "A [JSON API links object](http://jsonapi.org/format/1.0/#document-links) giving a URL to a web page containing a human-readable text describing the license (or licensing options if there are multiple) covering all the data and metadata provided by this database.\nClients are advised not to try automated parsing of this link or its content, but rather rely on the field `available_licenses` instead."
+          },
+          "available_licenses": {
+            "anyOf": [
+              {
+                "items": {
+                  "type": "string"
+                },
+                "type": "array"
+              },
+              {
+                "type": "null"
+              }
+            ],
+            "title": "Available Licenses",
+            "description": "List of [SPDX license identifiers](https://spdx.org/licenses/) specifying a set of alternative licenses available to the client for licensing the complete database, i.e., all the entries, metadata, and the content and structure of the database itself.\n\nIf more than one license is available to the client, the identifier of each one SHOULD be included in the list.\nInclusion of a license identifier in the list is a commitment of the database that the rights are in place to grant clients access to all the individual entries, all metadata, and the content and structure of the database itself according to the terms of any of these licenses (at the choice of the client).\nIf the licensing information provided via the field license omits licensing options specified in `available_licenses`, or if it otherwise contradicts them, a client MUST still be allowed to interpret the inclusion of a license in `available_licenses` as a full commitment from the database without exceptions, under the respective licenses.\nIf the database cannot make that commitment, e.g., if only part of the database is available under a license, the corresponding license identifier MUST NOT appear in `available_licenses` (but, rather, the field license is to be used to clarify the licensing situation.)\nAn empty list indicates that none of the SPDX licenses apply and that the licensing situation is clarified in human readable form in the field `license`.\nAn unknown value means that the database makes no commitment."
+          },
+          "available_licenses_for_entries": {
+            "anyOf": [
+              {
+                "items": {
+                  "type": "string"
+                },
+                "type": "array"
+              },
+              {
+                "type": "null"
+              }
+            ],
+            "title": "Available Licenses For Entries",
+            "description": "List of [SPDX license identifiers](https://spdx.org/licenses/) specifying a set of additional alternative licenses available to the client for licensing individual, and non-substantial sets of, database entries, metadata, and extracts from the database that do not constitute substantial parts of the database.\n\nNote that the definition of the field `available_licenses` implies that licenses specified in that field are available also for the licensing specified by this field, even if they are not explicitly included in the field `available_licenses_for_entries` or if it is `null` (however, the opposite relationship does not hold).\nIf `available_licenses` is unknown, only the licenses in `available_licenses_for_entries` apply."
           }
         },
         "type": "object",
diff --git a/openapi/openapi.json b/openapi/openapi.json
index 202fd81f..6ba71919 100644
--- a/openapi/openapi.json
+++ b/openapi/openapi.json
@@ -3,7 +3,7 @@
   "info": {
     "title": "OPTIMADE API",
     "description": "The [Open Databases Integration for Materials Design (OPTIMADE) consortium](https://www.optimade.org/) aims to make materials databases interoperational by developing a common REST API.\n\nThis specification is generated using [`optimade-python-tools`](https://github.com/Materials-Consortia/optimade-python-tools/tree/v1.1.11) v1.1.11.",
-    "version": "1.1.0"
+    "version": "1.2.0"
   },
   "paths": {
     "/info": {
@@ -1488,6 +1488,53 @@
             "title": "Is Index",
             "description": "If true, this is an index meta-database base URL (see section Index Meta-Database). If this member is not provided, the client MUST assume this is not an index meta-database base URL (i.e., the default is for `is_index` to be `false`).",
             "default": false
+          },
+          "license": {
+            "anyOf": [
+              {
+                "$ref": "#/components/schemas/Link"
+              },
+              {
+                "type": "string",
+                "minLength": 1,
+                "format": "uri"
+              },
+              {
+                "type": "null"
+              }
+            ],
+            "title": "License",
+            "description": "A [JSON API links object](http://jsonapi.org/format/1.0/#document-links) giving a URL to a web page containing a human-readable text describing the license (or licensing options if there are multiple) covering all the data and metadata provided by this database.\nClients are advised not to try automated parsing of this link or its content, but rather rely on the field `available_licenses` instead."
+          },
+          "available_licenses": {
+            "anyOf": [
+              {
+                "items": {
+                  "type": "string"
+                },
+                "type": "array"
+              },
+              {
+                "type": "null"
+              }
+            ],
+            "title": "Available Licenses",
+            "description": "List of [SPDX license identifiers](https://spdx.org/licenses/) specifying a set of alternative licenses available to the client for licensing the complete database, i.e., all the entries, metadata, and the content and structure of the database itself.\n\nIf more than one license is available to the client, the identifier of each one SHOULD be included in the list.\nInclusion of a license identifier in the list is a commitment of the database that the rights are in place to grant clients access to all the individual entries, all metadata, and the content and structure of the database itself according to the terms of any of these licenses (at the choice of the client).\nIf the licensing information provided via the field license omits licensing options specified in `available_licenses`, or if it otherwise contradicts them, a client MUST still be allowed to interpret the inclusion of a license in `available_licenses` as a full commitment from the database without exceptions, under the respective licenses.\nIf the database cannot make that commitment, e.g., if only part of the database is available under a license, the corresponding license identifier MUST NOT appear in `available_licenses` (but, rather, the field license is to be used to clarify the licensing situation.)\nAn empty list indicates that none of the SPDX licenses apply and that the licensing situation is clarified in human readable form in the field `license`.\nAn unknown value means that the database makes no commitment."
+          },
+          "available_licenses_for_entries": {
+            "anyOf": [
+              {
+                "items": {
+                  "type": "string"
+                },
+                "type": "array"
+              },
+              {
+                "type": "null"
+              }
+            ],
+            "title": "Available Licenses For Entries",
+            "description": "List of [SPDX license identifiers](https://spdx.org/licenses/) specifying a set of additional alternative licenses available to the client for licensing individual, and non-substantial sets of, database entries, metadata, and extracts from the database that do not constitute substantial parts of the database.\n\nNote that the definition of the field `available_licenses` implies that licenses specified in that field are available also for the licensing specified by this field, even if they are not explicitly included in the field `available_licenses_for_entries` or if it is `null` (however, the opposite relationship does not hold).\nIf `available_licenses` is unknown, only the licenses in `available_licenses_for_entries` apply."
           }
         },
         "type": "object",
diff --git a/optimade-version.json b/optimade-version.json
index e9e96a72..0df9258c 100644
--- a/optimade-version.json
+++ b/optimade-version.json
@@ -1,7 +1,7 @@
 {
   "schemaVersion": 1,
   "label": "OPTIMADE",
-  "message": "v1.1.0",
+  "message": "v1.2.0",
   "color": "yellowgreen",
   "logoSvg": "<svg version='1' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 55 55'><line x1='27' y1='14.5' x2='38.0' y2='7.94744111674' stroke='#9ed700' stroke-width='1.15' /><line x1='37.8253175473' y1='33.25' x2='38.0' y2='46.0525588833' stroke='#00acd9' stroke-width='1.15' /><line x1='16.1746824527' y1='33.25' x2='5' y2='27' stroke='#7a2dd0' stroke-width='1.15' /><line x1='49' y1='27' x2='38.0' y2='46.0525588833' stroke='#00acd9' stroke-width='1.15' /><line x1='38.0' y1='46.0525588833' x2='16.0' y2='46.0525588833' stroke='#e8e8e8' stroke-width='2' /><line x1='16.0' y1='46.0525588833' x2='5' y2='27' stroke='#7a2dd0' stroke-width='1.15' /><line x1='5' y1='27' x2='16.0' y2='7.94744111674' stroke='#e8e8e8' stroke-width='2' /><line x1='16.0' y1='7.94744111674' x2='38.0' y2='7.94744111674' stroke='#9ed700' stroke-width='1.15' /><line x1='38.0' y1='7.94744111674' x2='49' y2='27' stroke='#e8e8e8' stroke-width='2' /><circle cx='49' cy='27' r='3.5' fill='#00acd9' /><circle cx='38.0' cy='46.0525588833' r='3.5' fill='#00acd9' /><circle cx='16.0' cy='46.0525588833' r='3.5' fill='#7a2dd0' /><circle cx='5' cy='27' r='3.5' fill='#7a2dd0' /><circle cx='16.0' cy='7.94744111674' r='3.5' fill='#9ed700' /><circle cx='38.0' cy='7.94744111674' r='3.5' fill='#9ed700' /><line x1='27' y1='39.5' x2='16.1746824527' y2='33.25' stroke='#ff414d' stroke-width='1' /><line x1='16.1746824527' y1='33.25' x2='16.1746824527' y2='20.75' stroke='#ff414d' stroke-width='1' /><line x1='16.1746824527' y1='20.75' x2='27' y2='14.5' stroke='#ff414d' stroke-width='1' /><line x1='27' y1='14.5' x2='37.8253175473' y2='20.75' stroke='#ff414d' stroke-width='1' /><line x1='37.8253175473' y1='20.75' x2='37.8253175473' y2='33.25' stroke='#ff414d' stroke-width='1' /><line x1='37.8253175473' y1='33.25' x2='27' y2='39.5' stroke='#ff414d' stroke-width='1' /><circle cx='27' cy='39.5' r='2.5' fill='#ff414d' /><circle cx='16.1746824527' cy='33.25' r='2.5' fill='#ff414d' /><circle cx='16.1746824527' cy='20.75' r='2.5' fill='#ff414d' /><circle cx='27' cy='14.5' r='2.5' fill='#ff414d' /><circle cx='37.8253175473' cy='20.75' r='2.5' fill='#ff414d' /><circle cx='37.8253175473' cy='33.25' r='2.5' fill='#ff414d' /></svg>"
 }
diff --git a/optimade/__init__.py b/optimade/__init__.py
index bdec80a7..c1df3d23 100644
--- a/optimade/__init__.py
+++ b/optimade/__init__.py
@@ -1,2 +1,2 @@
 __version__ = "1.1.11"
-__api_version__ = "1.1.0"
+__api_version__ = "1.2.0"
diff --git a/optimade/models/baseinfo.py b/optimade/models/baseinfo.py
index f9479b24..12f63600 100644
--- a/optimade/models/baseinfo.py
+++ b/optimade/models/baseinfo.py
@@ -3,7 +3,7 @@ from typing import Annotated, Literal
 
 from pydantic import AnyHttpUrl, BaseModel, field_validator, model_validator
 
-from optimade.models.jsonapi import Resource
+from optimade.models.jsonapi import Link, Resource
 from optimade.models.types import SemanticVersion
 from optimade.models.utils import StrictField
 
@@ -108,6 +108,39 @@ Examples: `1.0.0`, `1.0.0-rc.2`.""",
             "(i.e., the default is for `is_index` to be `false`).",
         ),
     ] = False
+    license: Annotated[
+        Link | AnyHttpUrl | None,
+        StrictField(
+            ...,
+            description="""A [JSON API links object](http://jsonapi.org/format/1.0/#document-links) giving a URL to a web page containing a human-readable text describing the license (or licensing options if there are multiple) covering all the data and metadata provided by this database.
+Clients are advised not to try automated parsing of this link or its content, but rather rely on the field `available_licenses` instead.""",
+        ),
+    ] = None
+    available_licenses: Annotated[
+        list[str] | None,
+        StrictField(
+            ...,
+            description="""List of [SPDX license identifiers](https://spdx.org/licenses/) specifying a set of alternative licenses available to the client for licensing the complete database, i.e., all the entries, metadata, and the content and structure of the database itself.
+
+If more than one license is available to the client, the identifier of each one SHOULD be included in the list.
+Inclusion of a license identifier in the list is a commitment of the database that the rights are in place to grant clients access to all the individual entries, all metadata, and the content and structure of the database itself according to the terms of any of these licenses (at the choice of the client).
+If the licensing information provided via the field license omits licensing options specified in `available_licenses`, or if it otherwise contradicts them, a client MUST still be allowed to interpret the inclusion of a license in `available_licenses` as a full commitment from the database without exceptions, under the respective licenses.
+If the database cannot make that commitment, e.g., if only part of the database is available under a license, the corresponding license identifier MUST NOT appear in `available_licenses` (but, rather, the field license is to be used to clarify the licensing situation.)
+An empty list indicates that none of the SPDX licenses apply and that the licensing situation is clarified in human readable form in the field `license`.
+An unknown value means that the database makes no commitment.""",
+        ),
+    ] = None
+
+    available_licenses_for_entries: Annotated[
+        list[str] | None,
+        StrictField(
+            ...,
+            description="""List of [SPDX license identifiers](https://spdx.org/licenses/) specifying a set of additional alternative licenses available to the client for licensing individual, and non-substantial sets of, database entries, metadata, and extracts from the database that do not constitute substantial parts of the database.
+
+Note that the definition of the field `available_licenses` implies that licenses specified in that field are available also for the licensing specified by this field, even if they are not explicitly included in the field `available_licenses_for_entries` or if it is `null` (however, the opposite relationship does not hold).
+If `available_licenses` is unknown, only the licenses in `available_licenses_for_entries` apply.""",
+        ),
+    ] = None
 
     @model_validator(mode="after")
     def formats_and_endpoints_must_be_valid(self) -> "BaseInfoAttributes":
diff --git a/optimade/server/config.py b/optimade/server/config.py
index d330a35e..1ba800f1 100644
--- a/optimade/server/config.py
+++ b/optimade/server/config.py
@@ -239,6 +239,16 @@ class ServerConfig(BaseSettings):
         ),
     ] = "localhost:27017"
 
+    license: Annotated[
+        AnyHttpUrl | str | None,
+        Field(
+            description="""Either an SPDX license identifier or a URL to a human-readable license, used to populate the `license` field in the info response.
+
+If provided an SPDX license identifier, the `license` field value will be constructed from this identifier preprended with `https://spdx.org/licenses/CC-BY-4.0`, and the identifier will also be added to the `available_licenses` field directly.
+Otherwise, the license will be given as the provided URL and no SPDX identifier will be added to the `available_licenses` field. If your desired licensing scenario is more complex, please open an issue.""",
+        ),
+    ] = None
+
     links_collection: Annotated[
         str, Field(description="Mongo collection name for /links endpoint resources")
     ] = "links"
@@ -461,6 +471,29 @@ class ServerConfig(BaseSettings):
         res.update(value)
         return res
 
+    @field_validator("license", mode="before")
+    @classmethod
+    def check_license_info(cls, value: Any) -> AnyHttpUrl | None:
+        """Check that the license is either an spdx identifier or an accessible URL."""
+        import requests
+
+        if not value:
+            return None
+
+        try:
+            value = AnyHttpUrl(value)
+        except ValueError:
+            value = f"https://spdx.org/licenses/{value}"
+
+        # Check if license URL is accessible
+        try:
+            response = requests.head(str(value))
+            response.raise_for_status()
+        except requests.RequestException as exc:
+            raise ValueError(f"License URL '{value}' is not accessible: {exc}") from exc
+
+        return value
+
     @model_validator(mode="after")
     def use_real_mongo_override(self) -> "ServerConfig":
         """Overrides the `database_backend` setting with MongoDB and
diff --git a/optimade/server/routers/info.py b/optimade/server/routers/info.py
index 0c080b6b..73337530 100644
--- a/optimade/server/routers/info.py
+++ b/optimade/server/routers/info.py
@@ -5,7 +5,7 @@ from fastapi.exceptions import StarletteHTTPException
 
 from optimade import __api_version__
 from optimade.models import EntryInfoResource, EntryInfoResponse, InfoResponse
-from optimade.models.baseinfo import BaseInfoAttributes, BaseInfoResource
+from optimade.models.baseinfo import BaseInfoAttributes, BaseInfoResource, Link
 from optimade.server.config import CONFIG
 from optimade.server.routers.utils import get_base_url, meta_values
 from optimade.server.schemas import (
@@ -44,6 +44,10 @@ def get_info(request: Request) -> InfoResponse:
                 available_endpoints=["info", "links"] + list(ENTRY_INFO_SCHEMAS.keys()),
                 entry_types_by_format={"json": list(ENTRY_INFO_SCHEMAS.keys())},
                 is_index=False,
+                license=Link(href=CONFIG.license) if CONFIG.license else None,
+                available_licenses=[str(CONFIG.license).split("/")[-1]]
+                if "https://spdx.org" in str(CONFIG.license)
+                else None,
             ),
         )
 


Test patch:
diff --git a/tests/server/routers/test_info.py b/tests/server/routers/test_info.py
index b5e2369e..71f65e0f 100644
--- a/tests/server/routers/test_info.py
+++ b/tests/server/routers/test_info.py
@@ -20,9 +20,19 @@ class TestInfoEndpoint(RegularEndpointTests):
             "formats",
             "entry_types_by_format",
             "available_endpoints",
+            "license",
+            "available_licenses",
         ]
         self.check_keys(attributes, self.json_response["data"]["attributes"])
 
+        # Test that the spdx identifier is transformed correctly and found in both license fields
+        assert self.json_response["data"]["attributes"]["available_licenses"] == [
+            "CC-BY-4.0"
+        ]
+        assert self.json_response["data"]["attributes"]["license"] == {
+            "href": "https://spdx.org/licenses/CC-BY-4.0"
+        }
+
 
 class TestInfoStructuresEndpoint(RegularEndpointTests):
     """Tests for /info/structures"""
diff --git a/tests/test_config.json b/tests/test_config.json
index 43787f4e..de0a4653 100644
--- a/tests/test_config.json
+++ b/tests/test_config.json
@@ -37,5 +37,6 @@
         "structures": {
             "chemsys": "nelements"
         }
-    }
+    },
+    "license": "CC-BY-4.0"
 }