From 31db032b477e389791e81ac77940c64b9ee3b6ea Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Sat, 4 Jan 2025 19:24:23 -0600 Subject: [PATCH 1/9] Add routes Add the `/user/{username}/json` and `/user/{username}/json/` routes --- warehouse/routes.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/warehouse/routes.py b/warehouse/routes.py index c31158dbab82..7ad6232b27d5 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -640,6 +640,20 @@ def includeme(config): domain=warehouse, ) + config.add_route( + "legacy.api.json.user", + "/user/{username}/json", + factory="warehouse.legacy.api.json.user_factory", + domain=warehouse, + ) + + config.add_route( + "legacy.api.json.user_slash", + "/user/{username}/json/", + factory="warehouse.legacy.api.json.user_factory", + domain=warehouse, + ) + # Legacy Action URLs # TODO: We should probably add Warehouse routes for these that just error # and direct people to use upload.pypi.org From c8b88370b29553b5e0ff8aa05e9cff07e2f5d1f7 Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Sat, 4 Jan 2025 19:24:46 -0600 Subject: [PATCH 2/9] Add route tests --- tests/unit/test_routes.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 0de9e3f61694..236a6b7dc1a1 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -623,6 +623,18 @@ def add_redirect_rule(*args, **kwargs): factory="warehouse.legacy.api.json.release_factory", domain=warehouse, ), + pretend.call( + "legacy.api.json.user", + "/user/{username}/json", + factory="warehouse.legacy.api.json.user_factory", + domain=warehouse, + ), + pretend.call( + "legacy.api.json.user_slash", + "/user/{username}/json/", + factory="warehouse.legacy.api.json.user_factory", + domain=warehouse, + ), pretend.call("legacy.docs", docs_route_url), ] From 51ca71acb61893fac8a703d8b47f585a4c249530 Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Sat, 4 Jan 2025 20:29:24 -0600 Subject: [PATCH 3/9] add user factory --- warehouse/legacy/api/json.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 513699cbd665..8f9fd4216068 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -16,6 +16,7 @@ from sqlalchemy.exc import MultipleResultsFound, NoResultFound from sqlalchemy.orm import Load, contains_eager, joinedload +from warehouse.accounts.models import User from warehouse.cache.http import cache_control from warehouse.cache.origin import origin_cache from warehouse.packaging.models import ( @@ -312,6 +313,17 @@ def release_factory(request): return release +def user_factory(request): + username = request.matchdict["username"] + + try: + user = request.db.query(User).filter(User.username == username).one() + except NoResultFound: + return HTTPNotFound(headers=_CORS_HEADERS) + + return user + + @view_config( route_name="legacy.api.json.release", context=Release, From 9bcd3947cda48f04a1d2fc898ff98811616cd425 Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Sat, 4 Jan 2025 20:49:07 -0600 Subject: [PATCH 4/9] add route handlers --- warehouse/legacy/api/json.py | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index 8f9fd4216068..a6ed5af9970b 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -357,3 +357,42 @@ def json_release(release, request): ) def json_release_slash(release, request): return json_release(release, request) + + +@view_config( + route_name="legacy.api.json.user", + context=User, + renderer="json", +) +def json_user(user, request): + projects = [] + for project in user.projects: + latest_release = max(project.releases, key=lambda r: r.created, default=None) + if latest_release: + projects.append( + { + "name": project.name, + "last_released": latest_release.created.strftime( + "%Y-%m-%dT%H:%M:%S" + ), + "summary": latest_release.summary, + } + ) + + # Apply CORS headers. + request.response.headers.update(_CORS_HEADERS) + return { + "username": user.username, + "name": user.name or None, + "joined_at": user.date_joined.strftime("%Y-%m-%dT%H:%M:%S"), + "projects": projects, + } + + +@view_config( + route_name="legacy.api.json.user_slash", + context=User, + renderer="json", +) +def json_user_slash(user, request): + return json_user(user, request) From 130f8968cafc38da2409a4e364b10053589e4d14 Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Sat, 4 Jan 2025 20:50:11 -0600 Subject: [PATCH 5/9] add unit tests --- tests/unit/legacy/api/test_json.py | 62 ++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 3a7b029e8c7e..74a6314d0226 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -774,3 +774,65 @@ def test_normalizing_redirects(self, db_request): assert db_request.current_route_path.calls == [ pretend.call(name=release.project.normalized_name) ] + + +class TestUserFactory: + def test_user_factory(self, db_request): + user = UserFactory.create() + db_request.matchdict = {"username": user.username} + resp = json.user_factory(db_request) + assert resp == user + + def test_missing_user(self, db_request): + db_request.matchdict = {"username": "abc"} + resp = json.user_factory(db_request) + assert isinstance(resp, HTTPNotFound) + _assert_has_cors_headers(resp.headers) + + +class TestJSONUser: + def test_no_projects(self, db_request): + user = UserFactory.create() + resp = json.json_user(user, db_request) + assert resp["projects"] == [] + + def test_has_projects(self, db_request): + user = UserFactory.create() + project = ProjectFactory.create() + release = ReleaseFactory.create(project=project, version="1.0") + + user.projects.append(project) + + resp = json.json_user(user, db_request) + assert resp["projects"][0] == { + "name": project.name, + "last_released": release.created.strftime("%Y-%m-%dT%H:%M:%S"), + "summary": release.summary, + } + + def test_has_name(self, db_request): + user = UserFactory.create() + resp = json.json_user(user, db_request) + assert resp["name"] == user.name + assert resp["username"] == user.username + + def test_has_no_name(self, db_request): + user = UserFactory.create(name="") + resp = json.json_user(user, db_request) + assert resp["name"] is None + assert resp["username"] == user.username + + def test_project_without_releases(self, db_request): + user = UserFactory.create() + project = ProjectFactory.create() + user.projects.append(project) + + resp = json.json_user(user, db_request) + assert resp["projects"] == [] + + +class TestJSONUserSlash: + def test_redirect(self, db_request): + user = UserFactory.create() + resp = json.json_user_slash(user, db_request) + assert resp["username"] == user.username From a060617c5c9128fec236c2ba37c243b55ab58f1c Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Sat, 4 Jan 2025 20:50:14 -0600 Subject: [PATCH 6/9] add docs --- docs/user/api/json.md | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/docs/user/api/json.md b/docs/user/api/json.md index 98ac7680884d..f56c1c480587 100644 --- a/docs/user/api/json.md +++ b/docs/user/api/json.md @@ -482,6 +482,44 @@ For example, here is what a withdrawn vulnerability might look like: } ``` +### Get a user + +Route: `GET /user//json` + +Returns the same information found in the HTML profile page (`/user/`), but in a JSON format. +It contains the time of account creation, the username, the name (or null), and a list of the user's projects. + +Status codes: + +* `200 OK` - no error +* `404 Not Found` - User was not found + +Example request: +``` +GET /user/someuser/json HTTP/1.1 +Host: pypi.org +Accept: application/json +``` + +??? "Example JSON response" + + ```http + HTTP/1.1 200 OK + Content-Type: application/json; charset="UTF-8" + + { + "joined_at": "2025-01-01T06:35:12", + "name": "Some User", + "projects": [ + { + "last_released": "2025-01-04T08:47:36", + "name": "sampleproject", + "summary": "sample project" + } + ], + "username": "someuser" + } + ``` [Index API]: ./index-api.md [known vulnerabilities]: https://github.com/pypa/advisory-database From 9b2067a0f38cee21bac65ef73b5b92de4ffebf42 Mon Sep 17 00:00:00 2001 From: Robin5605 Date: Tue, 7 Jan 2025 20:59:36 -0600 Subject: [PATCH 7/9] use existing UserFactory --- tests/unit/legacy/api/test_json.py | 14 -------------- tests/unit/test_routes.py | 6 ++++-- warehouse/legacy/api/json.py | 11 ----------- warehouse/routes.py | 6 ++++-- 4 files changed, 8 insertions(+), 29 deletions(-) diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 74a6314d0226..b409b711a012 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -776,20 +776,6 @@ def test_normalizing_redirects(self, db_request): ] -class TestUserFactory: - def test_user_factory(self, db_request): - user = UserFactory.create() - db_request.matchdict = {"username": user.username} - resp = json.user_factory(db_request) - assert resp == user - - def test_missing_user(self, db_request): - db_request.matchdict = {"username": "abc"} - resp = json.user_factory(db_request) - assert isinstance(resp, HTTPNotFound) - _assert_has_cors_headers(resp.headers) - - class TestJSONUser: def test_no_projects(self, db_request): user = UserFactory.create() diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 236a6b7dc1a1..d31a2ee6f64b 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -626,13 +626,15 @@ def add_redirect_rule(*args, **kwargs): pretend.call( "legacy.api.json.user", "/user/{username}/json", - factory="warehouse.legacy.api.json.user_factory", + factory="warehouse.accounts.models:UserFactory", + traverse="/{username}", domain=warehouse, ), pretend.call( "legacy.api.json.user_slash", "/user/{username}/json/", - factory="warehouse.legacy.api.json.user_factory", + factory="warehouse.accounts.models:UserFactory", + traverse="/{username}", domain=warehouse, ), pretend.call("legacy.docs", docs_route_url), diff --git a/warehouse/legacy/api/json.py b/warehouse/legacy/api/json.py index a6ed5af9970b..49403a015854 100644 --- a/warehouse/legacy/api/json.py +++ b/warehouse/legacy/api/json.py @@ -313,17 +313,6 @@ def release_factory(request): return release -def user_factory(request): - username = request.matchdict["username"] - - try: - user = request.db.query(User).filter(User.username == username).one() - except NoResultFound: - return HTTPNotFound(headers=_CORS_HEADERS) - - return user - - @view_config( route_name="legacy.api.json.release", context=Release, diff --git a/warehouse/routes.py b/warehouse/routes.py index 7ad6232b27d5..3e929f680b17 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -643,14 +643,16 @@ def includeme(config): config.add_route( "legacy.api.json.user", "/user/{username}/json", - factory="warehouse.legacy.api.json.user_factory", + factory="warehouse.accounts.models:UserFactory", + traverse="/{username}", domain=warehouse, ) config.add_route( "legacy.api.json.user_slash", "/user/{username}/json/", - factory="warehouse.legacy.api.json.user_factory", + factory="warehouse.accounts.models:UserFactory", + traverse="/{username}", domain=warehouse, ) From 8a21728a97aa44098c7937867d16bd6921b78230 Mon Sep 17 00:00:00 2001 From: Robin <74519799+Robin5605@users.noreply.github.com> Date: Tue, 7 Jan 2025 23:51:01 -0600 Subject: [PATCH 8/9] docs: or null -> if present Co-authored-by: William Woodruff --- docs/user/api/json.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/api/json.md b/docs/user/api/json.md index f56c1c480587..b8511a8625ac 100644 --- a/docs/user/api/json.md +++ b/docs/user/api/json.md @@ -487,7 +487,7 @@ For example, here is what a withdrawn vulnerability might look like: Route: `GET /user//json` Returns the same information found in the HTML profile page (`/user/`), but in a JSON format. -It contains the time of account creation, the username, the name (or null), and a list of the user's projects. +It contains the time of account creation, the username, the name (if present), and a list of the user's projects. Status codes: From 0d474f7d2b3eaef6913681878aebde3ab2098cdc Mon Sep 17 00:00:00 2001 From: Robin <74519799+Robin5605@users.noreply.github.com> Date: Tue, 7 Jan 2025 23:52:08 -0600 Subject: [PATCH 9/9] docs: specify http langugae in codeblock Co-authored-by: William Woodruff --- docs/user/api/json.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/api/json.md b/docs/user/api/json.md index b8511a8625ac..03314a848104 100644 --- a/docs/user/api/json.md +++ b/docs/user/api/json.md @@ -495,11 +495,11 @@ Status codes: * `404 Not Found` - User was not found Example request: -``` + +```http GET /user/someuser/json HTTP/1.1 Host: pypi.org Accept: application/json -``` ??? "Example JSON response"