diff --git a/docs/user/api/json.md b/docs/user/api/json.md index 98ac7680884d..03314a848104 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/<username>/json` + +Returns the same information found in the HTML profile page (`/user/<username>`), but in a JSON format. +It contains the time of account creation, the username, the name (if present), and a list of the user's projects. + +Status codes: + +* `200 OK` - no error +* `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" + + ```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 diff --git a/tests/unit/legacy/api/test_json.py b/tests/unit/legacy/api/test_json.py index 3a7b029e8c7e..b409b711a012 100644 --- a/tests/unit/legacy/api/test_json.py +++ b/tests/unit/legacy/api/test_json.py @@ -774,3 +774,51 @@ def test_normalizing_redirects(self, db_request): assert db_request.current_route_path.calls == [ pretend.call(name=release.project.normalized_name) ] + + +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 diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 0de9e3f61694..d31a2ee6f64b 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -623,6 +623,20 @@ 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.accounts.models:UserFactory", + traverse="/{username}", + domain=warehouse, + ), + pretend.call( + "legacy.api.json.user_slash", + "/user/{username}/json/", + 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 513699cbd665..49403a015854 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 ( @@ -345,3 +346,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) diff --git a/warehouse/routes.py b/warehouse/routes.py index c31158dbab82..3e929f680b17 100644 --- a/warehouse/routes.py +++ b/warehouse/routes.py @@ -640,6 +640,22 @@ def includeme(config): domain=warehouse, ) + config.add_route( + "legacy.api.json.user", + "/user/{username}/json", + factory="warehouse.accounts.models:UserFactory", + traverse="/{username}", + domain=warehouse, + ) + + config.add_route( + "legacy.api.json.user_slash", + "/user/{username}/json/", + factory="warehouse.accounts.models:UserFactory", + traverse="/{username}", + 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