From 3e1f32d66d67030d3d5fe002fc45f335fe1d2ea8 Mon Sep 17 00:00:00 2001 From: Taylor Madore Date: Mon, 4 Dec 2023 19:47:15 -0500 Subject: [PATCH] add new API endpoint requests/latest The requests/latest endpoint will return the most recent request for a given repo_name and git ref. - The git repo_name and ref will be provided to the API as query parameters - The git repo_name is the namespaced repository name, not the full git URL (e.g. release-engineering/retrodep) - The most recent request among those with the same repo_name and ref is considered to be the one with the highest request_id Signed-off-by: Taylor Madore --- cachito/web/api_v1.py | 28 ++++++- cachito/web/static/api_v1.yaml | 42 ++++++++++ tests/test_api_v1.py | 137 +++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) diff --git a/cachito/web/api_v1.py b/cachito/web/api_v1.py index ad0a01eb1..1c1fb1bfd 100644 --- a/cachito/web/api_v1.py +++ b/cachito/web/api_v1.py @@ -15,7 +15,7 @@ from flask import stream_with_context from flask_login import current_user, login_required from opentelemetry import trace -from sqlalchemy import and_, func +from sqlalchemy import and_, desc, func from sqlalchemy.orm import joinedload, load_only from werkzeug.exceptions import BadRequest, Forbidden, Gone, InternalServerError, NotFound @@ -184,6 +184,32 @@ def get_request(request_id): return flask.jsonify(json) +def get_latest_request(): + """ + Retrieve the latest request for a repo/ref. + + The latest request will be the one with the highest id. + + :return: a Flask JSON response + :rtype: flask.Response + :raise NotFound: if the request is not found + """ + repo_name = flask.request.args.get("repo_name") + ref = flask.request.args.get("ref") + + request = ( + Request.query.filter(Request.ref == ref) + .filter(Request.repo.contains(repo_name)) + .order_by(desc(Request.id)) + .first() + ) + + if not request: + raise NotFound + + return flask.jsonify(request.to_json()) + + def get_request_config_files(request_id): """ Retrieve the configuration files associated with the given request. diff --git a/cachito/web/static/api_v1.yaml b/cachito/web/static/api_v1.yaml index 89372c548..9e9c5d8ce 100644 --- a/cachito/web/static/api_v1.yaml +++ b/cachito/web/static/api_v1.yaml @@ -341,6 +341,48 @@ paths: application/json: schema: $ref: '#/components/schemas/RequestUpdate' + "/requests/latest": + get: + operationId: cachito.web.api_v1.get_latest_request + summary: Get the latest request for a repo/ref + description: Return the latest request for a specified repo_name and ref + parameters: + - name: repo_name + required: true + in: query + description: The namespaced repository name (namespace/name) + schema: + type: string + maxLength: 200 + pattern: '^[\w.-]+(/[\w.-]+)+$' + example: release-engineering/retrodep + - name: ref + required: true + in: query + description: The git reference + schema: + type: string + minLength: 40 + maxLength: 40 + pattern: '^[a-f0-9]{40}$' + example: bc9767a71ede6e0084ae4a9e01dcd8b81c30b741 + responses: + "200": + description: The requested Cachito request + content: + application/json: + schema: + $ref: "#/components/schemas/Request" + "404": + description: The request wasn't found + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: The requested resource was not found "/requests/{request_id}/configuration-files": get: operationId: cachito.web.api_v1.get_request_config_files diff --git a/tests/test_api_v1.py b/tests/test_api_v1.py index 4cb4dcc29..2c14d2742 100644 --- a/tests/test_api_v1.py +++ b/tests/test_api_v1.py @@ -545,6 +545,143 @@ def test_datetime_validator(client, date, is_valid, expected_status): assert rv.status_code == expected_status +@pytest.fixture() +def latest_requests_db(app, db, worker_auth_env): + """Add requests to the db for testing the requests/latest endpoint.""" + data = [ + { + "repo": "https://github.com/org/foo.git", + "ref": "a50b93a32df1c9d700e3e80996845bc2e13be848", + }, + { + "repo": "https://github.com/org/foo.git", + "ref": "b50b93a32df1c9d700e3e80996845bc2e13be848", + }, + { + "repo": "https://github.com/org/foo.git", + "ref": "a50b93a32df1c9d700e3e80996845bc2e13be848", + }, + { + "repo": "https://github.com/org/bar.git", + "ref": "a50b93a32df1c9d700e3e80996845bc2e13be848", + }, + { + "repo": "https://otherforge.com/org/baz.git", + "ref": "c50b93a32df1c9d700e3e80996845bc2e13be848", + }, + { + "repo": "https://github.com/org/baz.git", + "ref": "c50b93a32df1c9d700e3e80996845bc2e13be848", + }, + ] + for item in data: + with app.test_request_context(environ_base=worker_auth_env): + request = Request.from_json(item) + db.session.add(request) + db.session.commit() + + +@pytest.mark.parametrize( + "query_params, latest_request_id", + [ + pytest.param( + {"repo_name": "org/foo", "ref": "a50b93a32df1c9d700e3e80996845bc2e13be848"}, + 3, + id="same_repo_same_ref", + ), + pytest.param( + {"repo_name": "org/foo", "ref": "b50b93a32df1c9d700e3e80996845bc2e13be848"}, + 2, + id="same_repo_different_ref", + ), + pytest.param( + {"repo_name": "org/bar", "ref": "a50b93a32df1c9d700e3e80996845bc2e13be848"}, + 4, + id="different_repo_same_ref", + ), + pytest.param( + {"repo_name": "org/baz", "ref": "c50b93a32df1c9d700e3e80996845bc2e13be848"}, + 6, + id="different_forge", + ), + ], +) +def test_get_latest_request_peekaboo(client, latest_requests_db, query_params, latest_request_id): + rv = client.get("/api/v1/requests/latest", query_string=query_params) + assert HTTPStatus.OK == rv.status_code + response = rv.json + assert latest_request_id == response["id"] + + +@pytest.mark.parametrize( + "query_params", + [ + pytest.param( + {"repo_name": "org/foo", "ref": "d50b93a32df1c9d700e3e80996845bc2e13be848"}, + id="ref_not_found", + ), + pytest.param( + {"repo_name": "org/qux", "ref": "a50b93a32df1c9d700e3e80996845bc2e13be848"}, + id="repo_not_found", + ), + ], +) +def test_get_latest_request_not_found_peekaboo(client, latest_requests_db, query_params): + rv = client.get("/api/v1/requests/latest", query_string=query_params) + assert HTTPStatus.NOT_FOUND == rv.status_code + + +@pytest.mark.parametrize( + "query_params, error_str", + [ + pytest.param( + {"repo_name": "org/repo", "ref": "c50b93a32df1c9d700e3e80996845bc2e13be84"}, + "'c50b93a32df1c9d700e3e80996845bc2e13be84' is too short", + id="ref_too_short", + ), + pytest.param( + {"repo_name": "org/repo", "ref": "c50b93a32df1c9d700e3e80996845bc2e13be8489"}, + "'c50b93a32df1c9d700e3e80996845bc2e13be8489' is too long", + id="ref_too_long", + ), + pytest.param( + {"repo_name": "org/repo", "ref": "c50b93a32df1c9d700e*e80996845bc2e13be848"}, + "'c50b93a32df1c9d700e*e80996845bc2e13be848' does not match", + id="ref_invalid_character", + ), + pytest.param( + { + "repo_name": "repo_name=org/" + ("repo" * 51), + "ref": "c50b93a32df1c9d700e3e80996845bc2e13be848", + }, + "reporepo' is too long", + id="repo_name_too_long", + ), + pytest.param( + {"repo_name": "git", "ref": "c50b93a32df1c9d700e3e80996845bc2e13be848"}, + "'git' does not match", + id="invalid_repo_name_format", + ), + pytest.param( + {"repo_name": "org/*", "ref": "c50b93a32df1c9d700e3e80996845bc2e13be848"}, + "'org/*' does not match ", + id="invalid_repo_name_character", + ), + pytest.param( + {"ref": "c50b93a32df1c9d700e3e80996845bc2e13be848"}, + "Missing query parameter 'repo_name'", + id="missing_repo_name", + ), + pytest.param({"repo_name": "org/repo"}, "Missing query parameter 'ref'", id="missing_ref"), + ], +) +def test_get_latest_request_invalid_input_peekaboo(app, client, query_params, error_str): + rv = client.get("/api/v1/requests/latest", query_string=query_params) + assert rv.status_code == 400 + response = rv.json + assert error_str in response["error"] + + def test_fetch_paginated_requests( app, auth_env, client, db, sample_deps_replace, sample_package, worker_auth_env, tmpdir ):