import BaseProvider from "./base-provider.js"
import * as _ from "../utils/lodash.js"
import * as errors from "../errors/index.js"
import * as utils from "../utils/index.js"
import jskos from "jskos-tools"
/**
* JSKOS Concept API.
*
* This class provides access to concept schemes and their concepts via JSKOS API in [JSKOS format](https://gbv.github.io/jskos/).
* See [jskos-server](https://github.com/gbv/jskos-server) for a JSKOS API reference implementation and [DANTE](https://api.dante.gbv.de/) for another API endpoint.
*
* To use it in a registry, specify `provider` as "ConceptApi" and provide the API base URL as `api`:
* ```json
* {
* "uri": "http://coli-conc.gbv.de/registry/coli-conc-concepts",
* "provider": "ConceptApi",
* "api": "https://coli-conc.gbv.de/api/"
* }
* ```
*
* If the `/status` endpoint can be queried, the remaining API methods will be taken from that. As a fallback, the default endpoints will be appended to `api`.
*
* Alternatively, you can provide the endpoints separately: `status`, `schemes`, `top`, `concepts`, `data`, `narrower`, `ancestors`, `types`, `suggest`, `search`
* Note that `schemes`, `top`, and `types` can also be provided as arrays.
*
* Additionally, the following JSKOS properties can be provided: `prefLabel`, `notation`, `definition`
*
* @extends BaseProvider
* @category Providers
*/
export default class ConceptApiProvider extends BaseProvider {
static supports = {
schemes: true,
top: true,
data: true,
concepts: true,
narrower: true,
ancestors: true,
types: true,
suggest: true,
search: true,
auth: true,
}
/**
* @private
*/
_prepare() {
// Set status endpoint only
if (this._api.api && this._api.status === undefined) {
this._api.status = utils.concatUrl(this._api.api, "/status")
}
}
/**
* @private
*/
_setup() {
// Implicitly fill `this._api` if necessary
if (this._api.api) {
const endpoints = {
schemes: "/voc",
top: "/voc/top",
concepts: "/voc/concepts",
data: "/data",
narrower: "/narrower",
ancestors: "/ancestors",
types: "/types",
suggest: "/suggest",
search: "/search",
}
for (let key of Object.keys(endpoints)) {
// Only override if undefined
if (this._api[key] === undefined) {
this._api[key] = utils.concatUrl(this._api.api, endpoints[key])
}
}
}
this.has.schemes = !!this._api.schemes
// If there is no scheme API endpoint, but a list of schemes is given, use that for schemes.
if (!this.has.schemes && Array.isArray(this.schemes)) {
this.has.schemes = true
}
this.has.top = !!this._api.top
this.has.data = !!this._api.data
this.has.concepts = !!this._api.concepts || this.has.data
this.has.narrower = !!this._api.narrower
this.has.ancestors = !!this._api.ancestors
this.has.types = !!this._api.types
this.has.suggest = !!this._api.suggest
this.has.search = !!this._api.search
this.has.auth = _.get(this._config, "auth.key") != null
this._defaultParams = {
// Default parameters mostly for DANTE
properties: "+created,issued,modified,editorialNote,scopeNote,note,definition,mappings,location",
}
}
/**
* Used by `registryForScheme` (see src/lib/CocodaSDK.js) to determine a provider config for a concept schceme.
*
* @param {Object} options
* @param {Object} options.url API URL for server
* @returns {Object} provider configuration
*/
static _registryConfigForBartocApiConfig({ url, scheme } = {}) {
if (!url || !scheme) {
return null
}
return {
api: url,
schemes: [scheme],
}
}
/**
* Returns the main vocabulary URI by requesting the scheme info and saving it in a cache.
*
* @private
*/
async _getSchemeUri(scheme) {
this._approvedSchemes = this._approvedSchemes || []
this._rejectedSchemes = this._rejectedSchemes || []
let _scheme = this._approvedSchemes.find(s => jskos.compare(scheme, s))
if (_scheme) {
return _scheme.uri
}
// Return null if it was already rejected
if (this._rejectedSchemes.find(s => jskos.compare(scheme, s))) {
return null
}
// Otherwise load scheme data and save in approved/rejected schemes
const schemes = await this.getSchemes({ params: {
uri: jskos.getAllUris(scheme).join("|"),
} })
const resultScheme = schemes.find(s => jskos.compare(s, scheme))
if (resultScheme) {
this._approvedSchemes.push({
uri: resultScheme.uri,
identifier: jskos.getAllUris(scheme),
})
return resultScheme.uri
} else {
this._rejectedSchemes.push({
uri: scheme.uri,
identifier: scheme.identifier,
})
return null
}
}
/**
* Returns all concept schemes.
*
* @param {Object} config
* @returns {Object[]} array of JSKOS concept scheme objects
*/
async getSchemes(config = {}) {
if (!this._api.schemes) {
// If an array of schemes is given, return that here
if (Array.isArray(this.schemes)) {
return this.schemes
}
throw new errors.MissingApiUrlError()
}
const schemes = await this.axios({
...config,
method: "get",
url: this._api.schemes,
params: {
...this._defaultParams,
// ? What should the default limit be?
limit: 500,
...(config.params || {}),
},
})
// If schemes were given in registry object, only request those schemes from API
if (Array.isArray(this.schemes)) {
return utils.withCustomProps(schemes.filter(s => jskos.isContainedIn(s, this.schemes)), schemes)
} else {
return schemes
}
}
/**
* Returns top concepts for a concept scheme.
*
* @param {Object} config
* @param {Object} config.scheme concept scheme object
* @returns {Object[]} array of JSKOS concept objects
*/
async getTop({ scheme, ...config }) {
if (!this._api.top) {
throw new errors.MissingApiUrlError()
}
if (!scheme) {
throw new errors.InvalidOrMissingParameterError({ parameter: "scheme" })
}
const schemeUri = await this._getSchemeUri(scheme)
if (!schemeUri) {
throw new errors.InvalidOrMissingParameterError({ parameter: "scheme", message: "Requested vocabulary seems to be unsupported by this API." })
}
if (Array.isArray(this._api.top)) {
return this._api.top
}
return this.axios({
...config,
method: "get",
url: this._api.top,
params: {
...this._defaultParams,
// ? What should the default limit be?
limit: 10000,
...(config.params || {}),
uri: schemeUri,
},
})
}
/**
* Returns details for a list of concepts.
*
* @param {Object} config
* @param {Object[]} config.concepts list of concept objects to load
* @returns {Object[]} array of JSKOS concept objects
*/
async getConcepts({ concepts, ...config }) {
if (this.has.data === false) {
throw new errors.MissingApiUrlError()
}
if (!concepts) {
throw new errors.InvalidOrMissingParameterError({ parameter: "concepts" })
}
if (!Array.isArray(concepts)) {
concepts = [concepts]
}
let uris = concepts.map(concept => concept.uri).filter(uri => uri != null)
return this.axios({
...config,
method: "get",
url: this._api.data,
params: {
...this._defaultParams,
// ? What should the default limit be?
limit: 500,
...(config.params || {}),
uri: uris.join("|"),
},
})
}
/**
* Returns narrower concepts for a concept.
*
* @param {Object} config
* @param {Object} config.concept concept object
* @returns {Object[]} array of JSKOS concept objects
*/
async getNarrower({ concept, ...config }) {
if (!this._api.narrower) {
throw new errors.MissingApiUrlError()
}
if (!concept || !concept.uri) {
throw new errors.InvalidOrMissingParameterError({ parameter: "concept" })
}
return this.axios({
...config,
method: "get",
url: this._api.narrower,
params: {
...this._defaultParams,
// ? What should the default limit be?
limit: 10000,
...(config.params || {}),
uri: concept.uri,
},
})
}
/**
* Returns ancestor concepts for a concept.
*
* @param {Object} config
* @param {Object} config.concept concept object
* @returns {Object[]} array of JSKOS concept objects
*/
async getAncestors({ concept, ...config }) {
if (!this._api.ancestors) {
throw new errors.MissingApiUrlError()
}
if (!concept || !concept.uri) {
throw new errors.InvalidOrMissingParameterError({ parameter: "concept" })
}
return this.axios({
...config,
method: "get",
url: this._api.ancestors,
params: {
...this._defaultParams,
// ? What should the default limit be?
limit: 10000,
...(config.params || {}),
uri: concept.uri,
},
})
}
/**
* Returns suggestion result in OpenSearch Suggest Format.
*
* @param {Object} config
* @param {string} config.search search string
* @param {Object} [config.scheme] concept scheme to search in
* @param {number} [config.limit=100] maximum number of search results (default might be overridden by registry)
* @param {string} [config.use=notation,label] which fields to search ("notation", "label" or "notation,label")
* @param {string[]} [config.types=[]] list of type URIs
* @param {string} [config.sort=score] sorting parameter
* @returns {Array} result in OpenSearch Suggest Format
*/
async suggest({ use = "notation,label", types = [], sort = "score", params = {}, ...config }) {
return this._search({
...config,
endpoint: "suggest",
params: {
...params,
type: types.join("|"),
use,
sort,
},
})
}
/**
* Returns search results in JSKOS Format.
*
* @param {Object} config
* @param {string} config.search search string
* @param {Object} [config.scheme] concept scheme to search in
* @param {number} [config.limit=100] maximum number of search results (default might be overridden by registry)
* @param {number} [config.offset=0] offset
* @param {string[]} [config.types=[]] list of type URIs
* @returns {Array} result in JSKOS Format
*/
async search({ types = [], params = {}, ...config }) {
return this._search({
...config,
endpoint: "search",
params: {
...params,
type: types.join("|"),
},
})
}
/**
* Returns concept scheme suggestion result in OpenSearch Suggest Format.
*
* @param {Object} config
* @param {string} config.search search string
* @param {number} [config.limit=100] maximum number of search results (default might be overridden by registry)
* @param {string} [config.use=notation,label] which fields to search ("notation", "label" or "notation,label")
* @param {string} [config.sort=score] sorting parameter
* @returns {Array} result in OpenSearch Suggest Format
*/
async vocSuggest({ use = "notation,label", sort = "score", params = {}, ...config }) {
return this._search({
...config,
endpoint: "voc-suggest",
params: {
...params,
use,
sort,
},
})
}
/**
* Returns concept scheme search results in JSKOS Format.
*
* @param {Object} config
* @param {string} config.search search string
* @param {number} [config.limit=100] maximum number of search results (default might be overridden by registry)
* @param {number} [config.offset=0] offset
* @returns {Array} result in JSKOS Format
*/
async vocSearch(config) {
return this._search({
...config,
endpoint: "voc-search",
})
}
async _search({ endpoint, scheme, search, limit, offset, params, url, ...config }) {
// Allows API URL override via parameter
url = url ?? this._api[endpoint]
if (!url) {
throw new errors.MissingApiUrlError()
}
if (!search) {
throw new errors.InvalidOrMissingParameterError({ parameter: "search" })
}
limit = limit || this._jskos.suggestResultLimit || 100
offset = offset || 0
// Scheme to search in
const voc = scheme && await this._getSchemeUri(scheme)
// Some registries use URL templates with {searchTerms}
url = url.replace("{searchTerms}", search)
return this.axios({
...config,
params: {
...this._defaultParams,
...params,
limit: limit,
count: limit, // Some endpoints use count instead of limit
offset,
search,
query: search,
voc,
},
method: "get",
url,
})
}
/**
* Returns a list of types.
*
* @param {Object} config
* @param {Object} [config.scheme] concept scheme to load types for
* @returns {Object[]} array of JSKOS type objects
*/
async getTypes({ scheme, ...config }) {
if (!this._api.types) {
throw new errors.MissingApiUrlError()
}
if (Array.isArray(this._api.types)) {
return this._api.types
}
const schemeUri = scheme && await this._getSchemeUri(scheme)
if (schemeUri) {
_.set(config, "params.uri", schemeUri)
}
let types = await this.axios({
...config,
method: "get",
url: this._api.types,
})
// It might be necessary to filter the result by its `inScheme` property (only if it exists, otherwise assume it belongs to the requested scheme URI).
if (schemeUri) {
types = types.filter(type => !type.inScheme || jskos.isContainedIn(scheme, type.inScheme))
}
return types
}
}
ConceptApiProvider.providerName = "ConceptApi"
ConceptApiProvider.providerType = "http://bartoc.org/api-type/jskos"