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 7 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 (or null), and a list of the user's projects.
Robin5605 marked this conversation as resolved.
Show resolved Hide resolved

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
```
Robin5605 marked this conversation as resolved.
Show resolved Hide resolved

??? "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
62 changes: 62 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,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
12 changes: 12 additions & 0 deletions tests/unit/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
]

Expand Down
51 changes: 51 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 @@ -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
Robin5605 marked this conversation as resolved.
Show resolved Hide resolved


@view_config(
route_name="legacy.api.json.release",
context=Release,
Expand Down Expand Up @@ -345,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)
14 changes: 14 additions & 0 deletions warehouse/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having a dedicates route for the slash variant seems a little funky (versus redirecting), but I see it's what we do for the other routes under legacy.api. Curious if @miketheman or @di has thoughts about this 🙂

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. For now I've just done what the other routes are doing but something cleaner would be nice.


# 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