Commit 83190be9 authored by Markus Scheidgen's avatar Markus Scheidgen
Browse files

Added optimade-python-tools to NOMADs fastapi api.

parent 73c75007
recursive-include dependencies/optimade-python-tools *.txt *.g *.py *.ini recursive-include dependencies/optimade-python-tools *.txt *.g *.py *.ini *.json
recursive-include nomad *.json *.j2 *.md *.yaml recursive-include nomad *.json *.j2 *.md *.yaml
include nomad/units/*.txt include nomad/units/*.txt
include README.md include README.md
......
...@@ -3,7 +3,7 @@ window.nomadEnv = { ...@@ -3,7 +3,7 @@ window.nomadEnv = {
'keycloakRealm': 'fairdi_nomad_test', 'keycloakRealm': 'fairdi_nomad_test',
'keycloakClientId': 'nomad_gui_dev', 'keycloakClientId': 'nomad_gui_dev',
'appBase': 'http://nomad-lab.eu/prod/rae/beta', 'appBase': 'http://nomad-lab.eu/prod/rae/beta',
'appBase': 'http://localhost:8000', 'appBase': 'http://localhost:8000/fairdi/nomad/latest',
'debug': false, 'debug': false,
'matomoEnabled': false, 'matomoEnabled': false,
'matomoUrl': 'https://nomad-lab.eu/fairdi/stat', 'matomoUrl': 'https://nomad-lab.eu/fairdi/stat',
......
...@@ -23,7 +23,7 @@ export const appBase = window.nomadEnv.appBase.replace(/\/$/, '') ...@@ -23,7 +23,7 @@ export const appBase = window.nomadEnv.appBase.replace(/\/$/, '')
// export const apiBase = 'http://nomad-lab.eu/prod/rae/api' // export const apiBase = 'http://nomad-lab.eu/prod/rae/api'
export const apiBase = `${appBase}/api` export const apiBase = `${appBase}/api`
export const apiV1Base = `${appBase}/api/v1` export const apiV1Base = `${appBase}/api/v1`
export const optimadeBase = `${appBase}/optimade` export const optimadeApi = `${appBase}/optimade/v1/extensions/docs`
export const guiBase = process.env.PUBLIC_URL export const guiBase = process.env.PUBLIC_URL
export const matomoUrl = window.nomadEnv.matomoUrl export const matomoUrl = window.nomadEnv.matomoUrl
export const matomoSiteId = window.nomadEnv.matomoSiteId export const matomoSiteId = window.nomadEnv.matomoSiteId
......
...@@ -36,7 +36,6 @@ import orjson ...@@ -36,7 +36,6 @@ import orjson
from nomad import config, utils as nomad_utils from nomad import config, utils as nomad_utils
from .api import blueprint as api_blueprint, api from .api import blueprint as api_blueprint, api
from .optimade import blueprint as optimade_blueprint, api as optimade
from .dcat import blueprint as dcat_blueprint from .dcat import blueprint as dcat_blueprint
from .docs import blueprint as docs_blueprint from .docs import blueprint as docs_blueprint
from .dist import blueprint as dist_blueprint from .dist import blueprint as dist_blueprint
...@@ -70,7 +69,6 @@ def output_json(data, code, headers=None): ...@@ -70,7 +69,6 @@ def output_json(data, code, headers=None):
api.representation('application/json')(output_json) api.representation('application/json')(output_json)
optimade.representation('application/json')(output_json)
@property # type: ignore @property # type: ignore
...@@ -100,7 +98,6 @@ app.config['SECRET_KEY'] = config.services.api_secret ...@@ -100,7 +98,6 @@ app.config['SECRET_KEY'] = config.services.api_secret
CORS(app) CORS(app)
app.register_blueprint(api_blueprint, url_prefix='/api') app.register_blueprint(api_blueprint, url_prefix='/api')
app.register_blueprint(optimade_blueprint, url_prefix='/optimade')
app.register_blueprint(dcat_blueprint, url_prefix='/dcat') app.register_blueprint(dcat_blueprint, url_prefix='/dcat')
app.register_blueprint(docs_blueprint, url_prefix='/docs') app.register_blueprint(docs_blueprint, url_prefix='/docs')
app.register_blueprint(dist_blueprint, url_prefix='/dist') app.register_blueprint(dist_blueprint, url_prefix='/dist')
......
...@@ -35,7 +35,7 @@ import gzip ...@@ -35,7 +35,7 @@ import gzip
from functools import wraps from functools import wraps
from nomad import search, config, datamodel, utils from nomad import search, config, datamodel, utils
from nomad.app.optimade import filterparser from nomad.app_fastapi.optimade import filterparser
from nomad.app.common import RFC3339DateTime, rfc3339DateTime from nomad.app.common import RFC3339DateTime, rfc3339DateTime
from nomad.files import Restricted from nomad.files import Restricted
......
#
# Copyright The NOMAD Authors.
#
# This file is part of NOMAD. See https://nomad-lab.eu for further info.
#
# 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.
#
'''
The optimade implementation of NOMAD.
'''
from flask import Blueprint
from flask_restplus import Api
from .api import blueprint, url, api
from .structures import Structure, StructuresInfo, StructureList
from .calculations import CalculationList, Calculation, CalculationInfo
from .infolinks import Info, Links
from .index import Info
from .filterparser import parse_filter
#
# Copyright The NOMAD Authors.
#
# This file is part of NOMAD. See https://nomad-lab.eu for further info.
#
# 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.
#
from flask import Blueprint
from flask_restplus import Api
import urllib.parse
from nomad import config
blueprint = Blueprint('optimade', __name__)
base_url = 'https://%s/%s/optimade' % (
config.services.api_host.strip('/'),
config.services.api_prefix.strip('/'))
def url(endpoint: str = None, version='v1', prefix=None, **kwargs):
''' Returns the full optimade api url (for a given endpoint) including query parameters. '''
if endpoint is not None:
url = '/' + endpoint
else:
url = ''
if version is not None:
url = '/' + version + url
if prefix is not None:
url = '/' + prefix + url
url = base_url + url
if len(kwargs) > 0:
return '%s?%s' % (url, urllib.parse.urlencode(kwargs))
else:
return url
api = Api(
blueprint,
version='1.0', title='NOMAD\'s OPTiMaDe API implementation',
description='NOMAD\'s OPTiMaDe API implementation, version 1.0.0.',
validate=True)
''' Provides the flask restplust api instance for the optimade api'''
# For some unknown reason it is necessary for each fr api to have a handler.
# Otherwise the global app error handler won't be called.
@api.errorhandler(Exception)
def errorhandler(error):
'''When an internal server error is caused by an unexpected exception.'''
return str(error)
#
# Copyright The NOMAD Authors.
#
# This file is part of NOMAD. See https://nomad-lab.eu for further info.
#
# 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.
#
from flask_restplus import Resource, abort
from flask import request
from nomad.datamodel import OptimadeEntry
from .api import api
from .common import base_request_args, base_search_request, nentries, ns
from .models import json_api_single_response_model, entry_listing_endpoint_parser, Meta, \
Links as LinksModel, single_entry_endpoint_parser, base_endpoint_parser, \
json_api_info_response_model, json_api_list_response_model, EntryDataObject, \
get_entry_properties, to_calc_with_metadata
from .filterparser import parse_filter, FilterException
@ns.route('/calculations')
class CalculationList(Resource):
@api.doc('calculations')
@api.response(400, 'Invalid requests, e.g. bad parameter.')
@api.expect(entry_listing_endpoint_parser, validate=True)
@api.marshal_with(json_api_list_response_model, skip_none=True, code=200)
def get(self):
''' Returns a list of calculations that match the given optimade filter expression. '''
response_fields = base_request_args()
try:
filter = request.args.get('filter', None)
page_limit = int(request.args.get('page_limit', 10))
page_number = int(request.args.get('page_number', 1))
sort = request.args.get('sort', request.args.get('sort', 'chemical_formula_reduced'))
order = 1
if sort[0:1] == '-':
order = -1
sort = sort[1:]
except Exception:
abort(400, message='bad parameter types') # TODO Specific json API error handling
sort_quantity = OptimadeEntry.m_def.all_quantities.get(sort, None)
if sort_quantity is None:
abort(400, message='cannot sort by %s' % sort) # TODO Specific json API error handling
sort_quantity_a_optimade = sort_quantity.m_get_annotations('optimade')
if not sort_quantity_a_optimade.sortable:
abort(400, message='cannot sort by %s' % sort) # TODO Specific json API error handling
search_request = base_search_request().include('calc_id', 'upload_id')
if filter is not None:
try:
search_request.query(parse_filter(filter))
except FilterException as e:
abort(400, message='Could not parse filter expression: %s' % str(e))
result = search_request.execute_paginated(
page=page_number,
per_page=page_limit,
order=order,
order_by='dft.optimade.%s' % sort)
returned = result['pagination']['total']
results = to_calc_with_metadata(result['results'])
assert len(results) == len(result['results']), 'archive and elasticsearch are not consistent'
return dict(
meta=Meta(
query=request.url,
returned=returned,
available=nentries(),
last_id=results[-1].calc_id if len(results) > 0 else None),
links=LinksModel(
'calculations',
returned=returned,
page_number=page_number,
page_limit=page_limit,
sort=sort, filter=filter),
data=[EntryDataObject(d, optimade_type='calculations', response_fields=response_fields) for d in results]
), 200
@ns.route('/calculations/<string:id>')
class Calculation(Resource):
@api.doc('calculation')
@api.response(400, 'Invalid requests, e.g. bad parameter.')
@api.response(404, 'Id does not exist.')
@api.expect(single_entry_endpoint_parser, validate=True)
@api.marshal_with(json_api_single_response_model, skip_none=True, code=200)
def get(self, id: str):
''' Retrieve a single calculation for the given id '''
response_fields = base_request_args()
search_request = base_search_request().search_parameters(calc_id=id)
result = search_request.execute_paginated(
page=1,
per_page=1)
available = result['pagination']['total']
results = to_calc_with_metadata(result['results'])
assert len(results) == len(result['results']), 'Mongodb and elasticsearch are not consistent'
if available == 0:
abort(404, 'The calculation with id %s does not exist' % id)
return dict(
meta=Meta(query=request.url, returned=1),
data=EntryDataObject(results[0], optimade_type='calculations', response_fields=response_fields)
), 200
@ns.route('/info/calculations')
class CalculationInfo(Resource):
@api.doc('calculations_info')
@api.response(400, 'Invalid requests, e.g. bad parameter.')
@api.expect(base_endpoint_parser, validate=True)
@api.marshal_with(json_api_info_response_model, skip_none=True, code=200)
def get(self):
''' Returns information about the calculation endpoint implementation '''
base_request_args()
result = {
'description': 'a calculation entry',
'properties': get_entry_properties(include_optimade=False),
'formats': ['json'],
'output_fields_by_format': {
'json': list(OptimadeEntry.m_def.all_properties.keys())}
}
return dict(
meta=Meta(query=request.url, returned=1),
data=result
), 200
#
# Copyright The NOMAD Authors.
#
# This file is part of NOMAD. See https://nomad-lab.eu for further info.
#
# 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.
#
from flask_restplus import abort
from flask import request
from elasticsearch_dsl import Q
from cachetools import cached, TTLCache
from nomad import search
from .api import api
ns = api.namespace('v1', description='The version v1 API namespace with all OPTiMaDe endpoints.')
# TODO replace with decorator that filters response_fields
def base_request_args():
if request.args.get('response_format', 'json') != 'json':
abort(400, 'Response format is not supported.')
properties_str = request.args.get('response_fields', None)
if properties_str is not None:
return properties_str.split(',')
return None
def base_search_request():
''' Creates a search request for all public and optimade enabled data. '''
return search.SearchRequest().owner('public', None).search_parameter('processed', True).query(
Q('exists', field='dft.optimade.elements')) # TODO use the elastic annotations when done
@cached(TTLCache(maxsize=1, ttl=60 * 60))
def nentries():
''' Gives the overall number of public calculations. '''
return search.SearchRequest().owner(owner_type='public').execute()['total']
#
# Copyright The NOMAD Authors.
#
# This file is part of NOMAD. See https://nomad-lab.eu for further info.
#
# 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.
#
from flask_restplus import Resource
from flask import request
from nomad import config
from .api import api, url
from .common import base_request_args
from .models import json_api_single_response_model, base_endpoint_parser, json_api_single_response_model, Meta, json_api_list_response_model
ns = api.namespace('index/v1', description='This is the OPTiMaDe index for NOMAD\' implementations.')
@ns.route('/info')
class Info(Resource):
@api.doc('index_info')
@api.response(400, 'Invalid requests, e.g. bad parameter.')
@api.expect(base_endpoint_parser, validate=True)
@api.marshal_with(json_api_single_response_model, skip_none=True, code=200)
def get(self):
''' Returns information relating to the API implementation- '''
base_request_args()
result = {
'type': 'info',
'id': '/',
'attributes': {
'api_version': '1.0.0',
'available_api_versions': [{
'url': url(prefix='index'),
'version': '1.0.0'
}],
'formats': ['json'],
'entry_types_by_format': {
'json': ['links', 'info']
},
'available_endpoints': ['links', 'info'],
'is_index': True
},
'relationships': {
'default': {
'data': {
'id': 'v1',
'type': 'links'
}
}
}
}
return dict(
meta=Meta(query=request.url, returned=1),
data=result
), 200
@ns.route('/links')
class Links(Resource):
@api.doc('index_links')
@api.response(400, 'Invalid requests, e.g. bad parameter.')
@api.expect(base_endpoint_parser, validate=True)
@api.marshal_with(json_api_list_response_model, skip_none=True, code=200)
def get(self):
''' Returns information relating to the API implementation- '''
base_request_args()
result = [
{
"type": "child",
"id": "v1",
"attributes": {
"name": config.meta.name,
"description": config.meta.description,
"base_url": {
"href": url(version=None),
},
"homepage": config.meta.homepage
}
}
]
return dict(
meta=Meta(query=request.url, returned=1),
data=result
), 200
#
# Copyright The NOMAD Authors.
#
# This file is part of NOMAD. See https://nomad-lab.eu for further info.
#
# 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.
#
from flask_restplus import Resource
from flask import request
from nomad import config
from .api import api, url
from .common import ns, base_request_args
from .models import json_api_single_response_model, base_endpoint_parser, Meta, \
json_api_list_response_model
@ns.route('/info')
class Info(Resource):
@api.doc('info')
@api.response(400, 'Invalid requests, e.g. bad parameter.')
@api.expect(base_endpoint_parser, validate=True)
@api.marshal_with(json_api_single_response_model, skip_none=True, code=200)
def get(self):
''' Returns information about this optimade implementation '''
base_request_args()
result = {
'type': 'info',
'id': '/',
'attributes': {
'api_version': '1.0.0',
'available_api_versions': [{
'url': url(),
'version': '1.0.0'
}],
'formats': ['json'],
'entry_types_by_format': {
'json': ['structures', 'calculations']
},
'available_endpoints': ['structures', 'calculations', 'info'],
'is_index': False
}
}
return dict(
meta=Meta(query=request.url, returned=1),
data=result
), 200
@ns.route('/links')
class Links(Resource):
@api.doc('links')
@api.response(400, 'Invalid requests, e.g. bad parameter.')
@api.expect(base_endpoint_parser, validate=True)
@api.marshal_with(json_api_list_response_model, skip_none=True, code=200)
def get(self):
''' Returns information about related optimade databases '''
base_request_args()
result = [
{
"type": "links",
"id": "index",
"attributes": {
"name": config.meta.name,
"description": config.meta.description,
"link_type": "root",
"base_url": {
"href": url(version=None, prefix='index'),
},
"homepage": config.meta.homepage
}
}
]
return dict(
meta=Meta(query=request.url, returned=1),
data=result
), 200
#
# Copyright The NOMAD Authors.
#
# This file is part of NOMAD. See https://nomad-lab.eu for further info.
#
# 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.
#
'''
All the API flask restplus models.
'''
from typing import Set, List, Dict, Any
from flask_restplus import fields