Repository: NatLibFi/Annif

Issue:
Expose vocab information via REST API
I think the Annif REST API could expose some more information about the vocabularies. Here is a list, ranging from easy to more difficult:

1. `/projects/{projectId}` could expose the vocabulary ID of a single project
2. `/projects/` could expose the vocabulary IDs of all returned projects
3. there could be a `/vocabs/` method that lists all vocabs along with basic information (similar to `annif list-vocabs`)
4. there could be a `/vocabs/{vocabId}` method that shows basic information about a single vocabulary
5. there could be additional methods that expose vocabulary concepts (but this is potentially getting into Skosmos territory...)

The reasons for implementing this:

- parity between the CLI and the REST API
- Finto AI, with the recent [functionality for guessing the language](https://github.com/NatLibFi/FintoAI/pull/21), would benefit from knowing the vocabulary ID of a project - now it has to guess it from the project ID

Hints from issue comments:

Solution patch:
diff --git a/annif/openapi/annif.yaml b/annif/openapi/annif.yaml
index 7f86f65f..89ffb0af 100644
--- a/annif/openapi/annif.yaml
+++ b/annif/openapi/annif.yaml
@@ -18,6 +18,19 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiInfo'
+  /vocabs:
+    get:
+      tags:
+      - Vocabulary information
+      summary: get a list of vocabularies
+      operationId: annif.rest.list_vocabs
+      responses:
+        "200":
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/VocabularyList'
   /projects:
     get:
       tags:
@@ -249,6 +262,35 @@ components:
           type: string
           example: my-backend
       description: A backend of a project
+    Vocabulary:
+      required:
+      - vocab_id
+      type: object
+      properties:
+        vocab_id:
+          type: string
+          example: yso
+        languages:
+          type: array
+          items:
+            type: string
+            example: en
+        size:
+          type: integer
+          example: 1000
+          nullable: true
+        loaded:
+          type: boolean
+          example: true
+      description: A subject vocabulary
+    VocabularyList:
+      type: object
+      properties:
+        vocabs:
+          type: array
+          items:
+            $ref: '#/components/schemas/Vocabulary'
+      description: A list of subject vocabularies
     Project:
       required:
       - backend
@@ -268,6 +310,13 @@ components:
           example: en
         backend:
           $ref: '#/components/schemas/ProjectBackend'
+        vocab:
+          $ref: '#/components/schemas/Vocabulary'
+          nullable: true
+        vocab_language:
+          type: string
+          example: en
+          nullable: true
         is_trained:
           type: boolean
           example: true
diff --git a/annif/project.py b/annif/project.py
index 3a4aa556..130e53b5 100644
--- a/annif/project.py
+++ b/annif/project.py
@@ -305,11 +305,24 @@ class AnnifProject(DatadirMixin):
 
     def dump(self) -> dict[str, str | dict | bool | datetime | None]:
         """return this project as a dict"""
+
+        try:
+            vocab = {
+                "vocab_id": self.vocab.vocab_id,
+                "languages": sorted(self.vocab.languages),
+            }
+            vocab_lang = self.vocab_lang
+        except ConfigurationException:
+            vocab = None
+            vocab_lang = None
+
         return {
             "project_id": self.project_id,
             "name": self.name,
             "language": self.language,
             "backend": {"backend_id": self.config.get("backend")},
+            "vocab": vocab,
+            "vocab_language": vocab_lang,
             "is_trained": self.is_trained,
             "modification_time": self.modification_time,
         }
diff --git a/annif/rest.py b/annif/rest.py
index d00a16f9..8717c406 100644
--- a/annif/rest.py
+++ b/annif/rest.py
@@ -59,6 +59,18 @@ def language_not_supported_error(lang: str) -> ConnexionResponse:
     )
 
 
+def list_vocabs() -> tuple:
+    """return a dict with vocabularies formatted according to OpenAPI spec"""
+
+    result = {
+        "vocabs": [
+            vocab.dump()
+            for vocab in annif.registry.get_vocabs(min_access=Access.public).values()
+        ]
+    }
+    return result, 200, {"Content-Type": "application/json"}
+
+
 def list_projects() -> tuple:
     """return a dict with projects formatted according to OpenAPI spec"""
 
diff --git a/annif/vocab.py b/annif/vocab.py
index 7f7e125c..624b4440 100644
--- a/annif/vocab.py
+++ b/annif/vocab.py
@@ -109,7 +109,10 @@ class AnnifVocabulary(DatadirMixin):
 
     @property
     def languages(self) -> list[str]:
-        return self.subjects.languages
+        try:
+            return self.subjects.languages
+        except NotInitializedException:
+            return []
 
     def load_vocabulary(
         self,
@@ -136,3 +139,22 @@ class AnnifVocabulary(DatadirMixin):
     def as_graph(self) -> Graph:
         """return the vocabulary as an rdflib graph"""
         return self.skos.graph
+
+    def dump(self) -> dict[str, str | list | int | bool]:
+        """return this vocabulary as a dict"""
+
+        try:
+            languages = list(sorted(self.languages))
+            size = len(self)
+            loaded = True
+        except NotInitializedException:
+            languages = []
+            size = None
+            loaded = False
+
+        return {
+            "vocab_id": self.vocab_id,
+            "languages": languages,
+            "size": size,
+            "loaded": loaded,
+        }


Test patch:
diff --git a/tests/test_project.py b/tests/test_project.py
index 6f1d55d6..6fcd2c9a 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -75,6 +75,11 @@ def test_get_project_fi_dump(registry):
         "backend": {
             "backend_id": "dummy",
         },
+        "vocab": {
+            "vocab_id": "dummy",
+            "languages": ["en", "fi"],
+        },
+        "vocab_language": "fi",
         "is_trained": True,
         "modification_time": None,
     }