From 7cfb17e3877689dc27e64d15680a5f21796d7720 Mon Sep 17 00:00:00 2001 From: Kelly Lockhart <2926089+kelockhart@users.noreply.github.com> Date: Fri, 23 May 2025 14:55:44 -0400 Subject: [PATCH 1/3] Added table of contents endpoint to list all export endpoints and their types --- .../tests/unittests/test_export_service.py | 50 +++++++ exportsrv/views.py | 127 +++++++++++------- 2 files changed, 126 insertions(+), 51 deletions(-) diff --git a/exportsrv/tests/unittests/test_export_service.py b/exportsrv/tests/unittests/test_export_service.py index 3ec23c4..e4007a4 100755 --- a/exportsrv/tests/unittests/test_export_service.py +++ b/exportsrv/tests/unittests/test_export_service.py @@ -1229,6 +1229,56 @@ def test_output_format_individual(self): exported = XMLFormat(solrdata.data).get_dublincore_xml(output_format=adsOutputFormat.individual) assert (exported == xmlTest.data_dublin_core_individual) + def test_toc(self): + response = self.client.get('/toc') + assert (response.json['BibTeX'] == {'type': 'tagged'}) + +class TestRegistryCoverage(unittest.TestCase): + def setUp(self): + self.current_app = app.create_app() + self.endpoint_registry = views.endpoint_registry + + # Collect view functions from Flask that are relevant (excluding static, /toc) + self.flask_view_funcs = { + rule.endpoint: self.current_app.view_functions[rule.endpoint] + for rule in self.current_app.url_map.iter_rules() + if rule.endpoint != 'static' and not rule.rule.startswith("/toc") + } + + # Set of handlers from Flask + self.flask_view_set = set(self.flask_view_funcs.values()) + + # Set of handlers from the registry + self.registry_handler_set = set( + entry["handler"] for entry in self.endpoint_registry.values() + ) + + def test_all_registered_handlers_are_in_flask(self): + """All handlers in the registry must be real Flask view functions.""" + missing = [] + for name, entry in self.endpoint_registry.items(): + handler = entry.get("handler") + if handler not in self.flask_view_set: + missing.append((name, handler.__name__)) + if missing: + self.fail(f"The following registry entries are not registered Flask views: {missing}") + + def test_all_flask_views_are_in_registry(self): + """All relevant Flask view functions must appear in the endpoint registry.""" + # Acceptable views that are intentionally not decorated/registered + ignored_names = {"ready", "alive", ""} + + # we have one registry entry for every pair of GET/POST endpoints + ignored_suffixes = ("_get", "_post") + missing_handlers = [ + func for func in self.flask_view_set + if func not in self.registry_handler_set + and not func.__name__.endswith(ignored_suffixes) + and func.__name__ not in ignored_names + ] + if missing_handlers: + missing_names = [func.__name__ for func in missing_handlers] + self.fail(f"These Flask view functions are not in the endpoint registry: {missing_names}") if __name__ == '__main__': unittest.main() diff --git a/exportsrv/views.py b/exportsrv/views.py index 84fb2a0..464063c 100755 --- a/exportsrv/views.py +++ b/exportsrv/views.py @@ -19,7 +19,15 @@ bp = Blueprint('export_service', __name__) - +endpoint_registry = {} +def register_endpoint(format: str, type_: str): + types = ['tagged', 'LaTeX', 'XML', 'text', 'custom'] + def decorator(func): + if type_ not in types: + raise ValueError(f"Type {type_} for format {format} is not an allowed value.") + endpoint_registry[format] = {"type": type_, "handler": func} + return func + return decorator def default_solr_fields(author_limit=0): """ @@ -359,6 +367,7 @@ def export_get(bibcode, style, format=-1): return get_solr_data(bibcodes=[bibcode], fields=default_solr_fields(), sort=sort, encode_style=adsFormatter().native_encoding(format)) +@register_endpoint('BibTeX', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/bibtex', methods=['POST']) def bibTex_format_export_post(): @@ -378,7 +387,7 @@ def bibTex_format_export_post(): journal_format=journal_format, export_output_format=export_output_format) return return_response(results, status) - +@register_endpoint('BibTeX', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/bibtex/', methods=['GET']) def bibTex_format_export_get(bibcode): @@ -391,7 +400,7 @@ def bibTex_format_export_get(bibcode): keyformat='%R', max_author=10, author_cutoff=200, journal_format=1, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('BibTeX ABS', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/bibtexabs', methods=['POST']) def bibTex_abs_format_export_post(): @@ -411,7 +420,7 @@ def bibTex_abs_format_export_post(): journal_format=journal_format, export_output_format=export_output_format) return return_response(results, status) - +@register_endpoint('BibTeX ABS', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/bibtexabs/', methods=['GET']) def bibTex_abs_format_export_get(bibcode): @@ -424,7 +433,7 @@ def bibTex_abs_format_export_get(bibcode): keyformat='%R', max_author=0, author_cutoff=200, journal_format=1, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('ADS', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/ads', methods=['POST']) def fielded_ads_format_export_post(): @@ -441,7 +450,7 @@ def fielded_ads_format_export_post(): return return_fielded_format_export(solr_data=results, fielded_style='ADS', export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('ADS', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/ads/', methods=['GET']) def fielded_ads_format_export_get(bibcode): @@ -452,7 +461,7 @@ def fielded_ads_format_export_get(bibcode): """ return return_fielded_format_export(solr_data=export_get(bibcode, 'ADS'), fielded_style='ADS', export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('EndNote', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/endnote', methods=['POST']) def fielded_endnote_format_export_post(): @@ -469,7 +478,7 @@ def fielded_endnote_format_export_post(): return return_fielded_format_export(solr_data=results, fielded_style='EndNote', export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('EndNote', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/endnote/', methods=['GET']) def fielded_endnote_format_export_get(bibcode): @@ -480,7 +489,7 @@ def fielded_endnote_format_export_get(bibcode): """ return return_fielded_format_export(solr_data=export_get(bibcode, 'EndNote'), fielded_style='EndNote', export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('ProCite', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/procite', methods=['POST']) def fielded_procite_format_export_post(): @@ -497,7 +506,7 @@ def fielded_procite_format_export_post(): return return_fielded_format_export(solr_data=results, fielded_style='ProCite', export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('ProCite', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/procite/', methods=['GET']) def fielded_procite_format_export_get(bibcode): @@ -508,7 +517,7 @@ def fielded_procite_format_export_get(bibcode): """ return return_fielded_format_export(solr_data=export_get(bibcode, 'ProCite'), fielded_style='ProCite', export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('RIS', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/ris', methods=['POST']) def fielded_refman_format_export_post(): @@ -525,7 +534,7 @@ def fielded_refman_format_export_post(): return return_fielded_format_export(solr_data=results, fielded_style='Refman', export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('RIS', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/ris/', methods=['GET']) def fielded_refman_format_export_get(bibcode): @@ -536,7 +545,7 @@ def fielded_refman_format_export_get(bibcode): """ return return_fielded_format_export(solr_data=export_get(bibcode, 'Refman'), fielded_style='Refman', export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('RefWorks', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/refworks', methods=['POST']) def fielded_refworks_format_export_post(): @@ -553,7 +562,7 @@ def fielded_refworks_format_export_post(): return return_fielded_format_export(solr_data=results, fielded_style='RefWorks', export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('RefWorks', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/refworks/', methods=['GET']) def fielded_refworks_format_export_get(bibcode): @@ -564,7 +573,7 @@ def fielded_refworks_format_export_get(bibcode): """ return return_fielded_format_export(solr_data=export_get(bibcode, 'RefWorks'), fielded_style='RefWorks', export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('MEDLARS', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/medlars', methods=['POST']) def fielded_medlars_format_export_post(): @@ -581,7 +590,7 @@ def fielded_medlars_format_export_post(): return return_fielded_format_export(solr_data=results, fielded_style='MEDLARS', export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('MEDLARS', 'tagged') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/medlars/', methods=['GET']) def fielded_medlars_format_export_get(bibcode): @@ -592,7 +601,7 @@ def fielded_medlars_format_export_get(bibcode): """ return return_fielded_format_export(solr_data=export_get(bibcode, 'MEDLARS'), fielded_style='MEDLARS', export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('DC-XML', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/dcxml', methods=['POST']) def xml_dublincore_format_export_post(): @@ -609,7 +618,7 @@ def xml_dublincore_format_export_post(): return return_xml_format_export(solr_data=results, xml_style='DublinCore', export_output_format=export_output_format) return return_response(results, status) - +@register_endpoint('DC-XML', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/dcxml/', methods=['GET']) def xml_dublincore_format_export_get(bibcode): @@ -620,7 +629,7 @@ def xml_dublincore_format_export_get(bibcode): """ return return_xml_format_export(solr_data=export_get(bibcode, 'DublinCore'), xml_style='DublinCore', export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('REF-XML', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/refxml', methods=['POST']) def xml_ref_format_export_post(): @@ -637,7 +646,7 @@ def xml_ref_format_export_post(): return return_xml_format_export(solr_data=results, xml_style='Reference', export_output_format=export_output_format) return return_response(results, status) - +@register_endpoint('REF-XML', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/refxml/', methods=['GET']) def xml_ref_format_export_get(bibcode): @@ -648,7 +657,7 @@ def xml_ref_format_export_get(bibcode): """ return return_xml_format_export(solr_data=export_get(bibcode, 'Reference'), xml_style='Reference', export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('REFABS-XML', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/refabsxml', methods=['POST']) def xml_refabs_format_export_post(): @@ -665,7 +674,7 @@ def xml_refabs_format_export_post(): return return_xml_format_export(solr_data=results, xml_style='ReferenceAbs', export_output_format=export_output_format) return return_response(results, status) - +@register_endpoint('REFABS-XML', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/refabsxml/', methods=['GET']) def xml_refabs_format_export_get(bibcode): @@ -676,7 +685,7 @@ def xml_refabs_format_export_get(bibcode): """ return return_xml_format_export(solr_data=export_get(bibcode, 'ReferenceAbs'), xml_style='ReferenceAbs', export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('JATS-XML', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/jatsxml', methods=['POST']) def xml_jats_format_export_post(): @@ -693,7 +702,7 @@ def xml_jats_format_export_post(): return return_xml_format_export(solr_data=results, xml_style='JATS', export_output_format=export_output_format) return return_response(results, status) - +@register_endpoint('JATS-XML', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/jatsxml/', methods=['GET']) def xml_jats_format_export_get(bibcode): @@ -704,7 +713,7 @@ def xml_jats_format_export_get(bibcode): """ return return_xml_format_export(solr_data=export_get(bibcode, 'JATS'), xml_style='JATS', export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('AASTeX', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/aastex', methods=['POST']) def csl_aastex_format_export_post(): @@ -724,7 +733,7 @@ def csl_aastex_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('AASTeX', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/aastex/', methods=['GET']) def csl_aastex_format_export_get(bibcode): @@ -769,6 +778,7 @@ def csl_aastex_psj_format_export_get(bibcode): csl_style='aastex-psj', export_format=adsFormatter.latex, journal_format=adsJournalFormat.macro, export_output_format=adsOutputFormat.default, request_type='GET') +@register_endpoint('Icarus', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/icarus', methods=['POST']) def csl_icarus_format_export_post(): @@ -787,7 +797,7 @@ def csl_icarus_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('Icarus', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/icarus/', methods=['GET']) def csl_icarus_format_export_get(bibcode): @@ -800,7 +810,7 @@ def csl_icarus_format_export_get(bibcode): csl_style='icarus', export_format=adsFormatter.latex, journal_format=adsJournalFormat.full, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('MNRAS', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/mnras', methods=['POST']) def csl_mnras_format_export_post(): @@ -819,7 +829,7 @@ def csl_mnras_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('MNRAS', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/mnras/', methods=['GET']) def csl_mnras_format_export_get(bibcode): @@ -832,7 +842,7 @@ def csl_mnras_format_export_get(bibcode): csl_style='mnras', export_format=adsFormatter.latex, journal_format=adsJournalFormat.full, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('Solar Physics', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/soph', methods=['POST']) def csl_soph_format_export_post(): @@ -851,7 +861,7 @@ def csl_soph_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('Solar Physics', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/soph/', methods=['GET']) def csl_soph_format_export_get(bibcode): @@ -864,7 +874,7 @@ def csl_soph_format_export_get(bibcode): csl_style='soph', export_format=adsFormatter.latex, journal_format=adsJournalFormat.full, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('ASPC', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/aspc', methods=['POST']) def csl_aspc_format_export_post(): @@ -883,7 +893,7 @@ def csl_aspc_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('ASP Conference', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/aspc/', methods=['GET']) def csl_aspc_format_export_get(bibcode): @@ -896,7 +906,7 @@ def csl_aspc_format_export_get(bibcode): csl_style='aspc', export_format=adsFormatter.latex, journal_format=adsJournalFormat.full, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('AAS Journals', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/aasj', methods=['POST']) def csl_aasj_format_export_post(): @@ -915,7 +925,7 @@ def csl_aasj_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('AAS Journals', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/aasj/', methods=['GET']) def csl_aasj_format_export_get(bibcode): @@ -928,7 +938,7 @@ def csl_aasj_format_export_get(bibcode): csl_style='aasj', export_format=adsFormatter.latex, journal_format=adsJournalFormat.full, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('APS Journals', 'text') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/apsj', methods=['POST']) def csl_apsj_format_export_post(): @@ -947,7 +957,7 @@ def csl_apsj_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('APS Journals', 'text') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/apsj/', methods=['GET']) def csl_apsj_format_export_get(bibcode): @@ -960,7 +970,7 @@ def csl_apsj_format_export_get(bibcode): csl_style='apsj', export_format=adsFormatter.unicode, journal_format=adsJournalFormat.full, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('IEEE', 'text') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/ieee', methods=['POST']) def csl_ieee_format_export_post(): @@ -979,7 +989,7 @@ def csl_ieee_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('IEEE', 'text') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/ieee/', methods=['GET']) def csl_ieee_format_export_get(bibcode): @@ -992,7 +1002,7 @@ def csl_ieee_format_export_get(bibcode): csl_style='ieee', export_format=adsFormatter.unicode, journal_format=adsJournalFormat.full, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('AGU', 'text') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/agu', methods=['POST']) def csl_agu_format_export_post(): @@ -1011,7 +1021,7 @@ def csl_agu_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('AGU', 'text') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/agu/', methods=['GET']) def csl_agu_format_export_get(bibcode): @@ -1024,7 +1034,7 @@ def csl_agu_format_export_get(bibcode): csl_style='agu', export_format=adsFormatter.unicode, journal_format=adsJournalFormat.full, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('GSA', 'text') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/gsa', methods=['POST']) def csl_gsa_format_export_post(): @@ -1043,7 +1053,7 @@ def csl_gsa_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('GSA', 'text') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/gsa/', methods=['GET']) def csl_gsa_format_export_get(bibcode): @@ -1056,7 +1066,7 @@ def csl_gsa_format_export_get(bibcode): csl_style='gsa', export_format=adsFormatter.unicode, journal_format=adsJournalFormat.full, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('AMS (Meteorological)', 'text') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/ams', methods=['POST']) def csl_ams_format_export_post(): @@ -1075,7 +1085,7 @@ def csl_ams_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('AMS (Meteorological)', 'text') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/ams/', methods=['GET']) def csl_ams_format_export_get(bibcode): @@ -1088,7 +1098,7 @@ def csl_ams_format_export_get(bibcode): csl_style='ams', export_format=adsFormatter.unicode, journal_format=adsJournalFormat.full, export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('CSL', 'custom') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/csl', methods=['POST']) def csl_format_export(): @@ -1121,7 +1131,7 @@ def csl_format_export(): return return_response(results, status) - +@register_endpoint('custom', 'custom') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/custom', methods=['POST']) def custom_format_export(): @@ -1153,7 +1163,7 @@ def custom_format_export(): return return_response(results, status) - +@register_endpoint('VOTable', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/votable', methods=['POST']) def votable_format_export_post(): @@ -1170,7 +1180,7 @@ def votable_format_export_post(): return return_votable_format_export(solr_data=results, export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('VOTable', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/votable/', methods=['GET']) def votable_format_export_get(bibcode): @@ -1181,7 +1191,7 @@ def votable_format_export_get(bibcode): """ return return_votable_format_export(solr_data=export_get(bibcode, 'VOTable'), export_output_format=adsOutputFormat.default, request_type='GET') - +@register_endpoint('RSS', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/rss', methods=['POST']) def rss_format_export_post(): @@ -1202,7 +1212,7 @@ def rss_format_export_post(): return return_rss_format_export(solr_data=results, link=link, export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('RSS', 'XML') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/rss//', defaults={'link': ''}, methods=['GET']) @bp.route('/rss//', methods=['GET']) @@ -1214,3 +1224,18 @@ def rss_format_export_get(bibcode, link): :return: """ return return_rss_format_export(solr_data=export_get(bibcode, 'RSS'), link=link, export_output_format=adsOutputFormat.default, request_type='GET') + +@advertise(scopes=[], rate_limit=[1000, 3600 * 24]) +@bp.route('/toc', methods=['GET']) +def export_toc_get(): + """ + Returns dict of available export formats with their format type + """ + results = { + route: {"type": info["type"]} + for route, info in endpoint_registry.items() + } + + # using POST here returns JSON + return return_response(results, 200, request_type='POST') + From 050cf3c00ffe5a5e4dbbceabee45adfc9a9ac4d0 Mon Sep 17 00:00:00 2001 From: Kelly Lockhart <2926089+kelockhart@users.noreply.github.com> Date: Fri, 23 May 2025 15:11:17 -0400 Subject: [PATCH 2/3] Update after rebase --- exportsrv/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/exportsrv/views.py b/exportsrv/views.py index 464063c..2d4a9b5 100755 --- a/exportsrv/views.py +++ b/exportsrv/views.py @@ -746,6 +746,7 @@ def csl_aastex_format_export_get(bibcode): csl_style='aastex', export_format=adsFormatter.latex, journal_format=adsJournalFormat.macro, export_output_format=adsOutputFormat.default, request_type='GET') +@register_endpoint('AASTeX (PSJ)', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/aastex-psj', methods=['POST']) def csl_aastex_psj_format_export_post(): @@ -765,7 +766,7 @@ def csl_aastex_psj_format_export_post(): export_output_format=export_output_format, request_type='POST') return return_response(results, status) - +@register_endpoint('AASTeX (PSJ)', 'LaTeX') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) @bp.route('/aastex-psj/', methods=['GET']) def csl_aastex_psj_format_export_get(bibcode): From d7890c1cf14f2e8900382dcdac414fac10f62f86 Mon Sep 17 00:00:00 2001 From: Kelly Lockhart <2926089+kelockhart@users.noreply.github.com> Date: Mon, 2 Jun 2025 11:24:44 -0400 Subject: [PATCH 3/3] Updated response format, added route to output --- exportsrv/app.py | 14 ++++++++- .../tests/unittests/test_export_service.py | 31 +++++++++++++------ exportsrv/views.py | 27 ++++++++++------ 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/exportsrv/app.py b/exportsrv/app.py index 55e33df..3eafe99 100644 --- a/exportsrv/app.py +++ b/exportsrv/app.py @@ -5,7 +5,17 @@ from adsmutils import ADSFlask -from exportsrv.views import bp +from exportsrv.views import bp, endpoint_registry + +def attach_routes_to_registry(app): + for rule in app.url_map.iter_rules(): + if rule.endpoint == 'static': + continue + view_func = app.view_functions[rule.endpoint] + for name, entry in endpoint_registry.items(): + if view_func in entry["handlers"]: + if rule.rule not in entry["routes"]: + entry["routes"].append(rule.rule) def create_app(**config): """ @@ -23,6 +33,8 @@ def create_app(**config): Discoverer(app) app.register_blueprint(bp) + + attach_routes_to_registry(app) return app if __name__ == '__main__': diff --git a/exportsrv/tests/unittests/test_export_service.py b/exportsrv/tests/unittests/test_export_service.py index e4007a4..a7aa6c2 100755 --- a/exportsrv/tests/unittests/test_export_service.py +++ b/exportsrv/tests/unittests/test_export_service.py @@ -1229,20 +1229,21 @@ def test_output_format_individual(self): exported = XMLFormat(solrdata.data).get_dublincore_xml(output_format=adsOutputFormat.individual) assert (exported == xmlTest.data_dublin_core_individual) - def test_toc(self): - response = self.client.get('/toc') - assert (response.json['BibTeX'] == {'type': 'tagged'}) + def test_manifest(self): + response = self.client.get('/manifest') + assert (response.json[0] == {'name': 'BibTeX', 'type': 'tagged', 'route': '/bibtex'}) class TestRegistryCoverage(unittest.TestCase): def setUp(self): self.current_app = app.create_app() + app.attach_routes_to_registry(self.current_app) self.endpoint_registry = views.endpoint_registry - # Collect view functions from Flask that are relevant (excluding static, /toc) + # Collect view functions from Flask that are relevant (excluding static, /manifest) self.flask_view_funcs = { rule.endpoint: self.current_app.view_functions[rule.endpoint] for rule in self.current_app.url_map.iter_rules() - if rule.endpoint != 'static' and not rule.rule.startswith("/toc") + if rule.endpoint != 'static' and not rule.rule.startswith("/manifest") } # Set of handlers from Flask @@ -1250,16 +1251,18 @@ def setUp(self): # Set of handlers from the registry self.registry_handler_set = set( - entry["handler"] for entry in self.endpoint_registry.values() + handler + for entry in self.endpoint_registry.values() + for handler in entry.get("handlers", []) ) def test_all_registered_handlers_are_in_flask(self): """All handlers in the registry must be real Flask view functions.""" missing = [] for name, entry in self.endpoint_registry.items(): - handler = entry.get("handler") - if handler not in self.flask_view_set: - missing.append((name, handler.__name__)) + for handler in entry.get("handlers", []): + if handler not in self.flask_view_set: + missing.append((name, handler.__name__)) if missing: self.fail(f"The following registry entries are not registered Flask views: {missing}") @@ -1280,5 +1283,15 @@ def test_all_flask_views_are_in_registry(self): missing_names = [func.__name__ for func in missing_handlers] self.fail(f"These Flask view functions are not in the endpoint registry: {missing_names}") + def test_all_registry_entries_have_routes(self): + """Ensure that each endpoint in the registry has at least one route recorded.""" + missing = [] + for name, entry in self.endpoint_registry.items(): + if not entry.get("routes"): + missing.append(name) + if missing: + self.fail(f"The following registry entries are missing route information: {missing}") + + if __name__ == '__main__': unittest.main() diff --git a/exportsrv/views.py b/exportsrv/views.py index 2d4a9b5..dd27a46 100755 --- a/exportsrv/views.py +++ b/exportsrv/views.py @@ -19,13 +19,15 @@ bp = Blueprint('export_service', __name__) -endpoint_registry = {} -def register_endpoint(format: str, type_: str): +endpoint_registry = {} # name -> { type, handlers, routes } +def register_endpoint(name: str, type_: str): types = ['tagged', 'LaTeX', 'XML', 'text', 'custom'] def decorator(func): if type_ not in types: - raise ValueError(f"Type {type_} for format {format} is not an allowed value.") - endpoint_registry[format] = {"type": type_, "handler": func} + raise ValueError(f"Type {type_} for format {name} is not an allowed value.") + entry = endpoint_registry.setdefault(name, {"type": type_, "handlers": [], "routes": []}) + if func not in entry["handlers"]: + entry["handlers"].append(func) return func return decorator @@ -1227,15 +1229,20 @@ def rss_format_export_get(bibcode, link): return return_rss_format_export(solr_data=export_get(bibcode, 'RSS'), link=link, export_output_format=adsOutputFormat.default, request_type='GET') @advertise(scopes=[], rate_limit=[1000, 3600 * 24]) -@bp.route('/toc', methods=['GET']) -def export_toc_get(): +@bp.route('/manifest', methods=['GET']) +def export_manifest_get(): """ Returns dict of available export formats with their format type """ - results = { - route: {"type": info["type"]} - for route, info in endpoint_registry.items() - } + results = [] + for name, info in endpoint_registry.items(): + routes = info.get("routes", []) + route = routes[0] if routes else None + results.append({ + "name": name, + "type": info.get("type"), + "route": route + }) # using POST here returns JSON return return_response(results, 200, request_type='POST')