Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JSON user API #17352

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/user/api/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 48 additions & 0 deletions tests/unit/legacy/api/test_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions tests/unit/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]

Expand Down
40 changes: 40 additions & 0 deletions warehouse/legacy/api/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
16 changes: 16 additions & 0 deletions warehouse/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down