Skip to content

Commit

Permalink
Add hypermedia API to replace XML-RPC and simple
Browse files Browse the repository at this point in the history
Adds a new API that covers the usage of the XMP-RPC and the simple api.
pypi#284

This work is intended as a proof of concept for how a hypermedia API
could be implemented, setting up the patterns that can be extended to
cover the rest of the API. The new API introduces pagination to reduce
the load for list views.  Serializers are used to increase
maintainability and code reuse. Some filtering is added to meet the use
cases of XML-RPC. Many thanks to @werwty for hacking out an initial
implementation which has been squashed.

Introduces new dependencies:
   apispec==0.37.0 : Used to generate an api spec at /api/
   marshmallow==3.0.0b10 : Used to serialize responses
   PyYAML==3.12 : Dependency of apispec

All new endpoints are added to a new domain, "sandbox".

Note: Locally, all subdomains were treated just like the actual domain
so I was unable to make the subdomain works as expected. I followed the
pattern that forklift uses, and guessed how it should work.
  • Loading branch information
asmacdo authored and therealphildini committed May 8, 2019
1 parent e22cf32 commit c4b8ce3
Show file tree
Hide file tree
Showing 11 changed files with 707 additions and 0 deletions.
2 changes: 2 additions & 0 deletions dev/environment
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ TOKEN_EMAIL_SECRET="an insecure email verification secret key"
TOKEN_TWO_FACTOR_SECRET="an insecure two-factor auth secret key"

WAREHOUSE_LEGACY_DOMAIN=pypi.python.org

HYPERMEDIA_API=api.pypi.org
3 changes: 3 additions & 0 deletions requirements/main.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
alembic>=0.7.0
apispec
Automat
argon2-cffi
Babel
Expand All @@ -21,6 +22,7 @@ itsdangerous
Jinja2>=2.8
limits
lxml
marshmallow
mistune
msgpack
packaging>=15.2
Expand All @@ -39,6 +41,7 @@ pyramid_retry>=0.3
pyramid_rpc>=0.7
pyramid_services>=2.1
pyramid_tm>=0.12
PyYaml
raven
readme_renderer[md]>=0.7.0
requests
Expand Down
6 changes: 6 additions & 0 deletions requirements/main.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ alembic==1.0.10 \
amqp==2.4.2 \
--hash=sha256:043beb485774ca69718a35602089e524f87168268f0d1ae115f28b88d27f92d7 \
--hash=sha256:35a3b5006ca00b21aaeec8ceea07130f07b902dd61bfe42815039835f962f5f1
apispec==0.37.0 \
--hash=sha256:8c497b70f0095da521b41ea1e85cd171302b80e4bb6382cb0055cadf4980ced1
argon2-cffi==19.1.0 \
--hash=sha256:1029fef2f7808a89e3baa306f5ace36e768a2d847ee7b056399adcd7707f6256 \
--hash=sha256:206857d870c6ca3c92514ca70a3c371be47383f7ae6a448f5a16aa17baa550ba \
Expand Down Expand Up @@ -311,6 +313,8 @@ markupsafe==1.1.1 \
--hash=sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f \
--hash=sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f \
--hash=sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7
marshmallow==3.0.0b10 \
--hash=sha256:be2541dfd0fe7fdbb6ab83ab187e5190dfe2e169b68bb6ff982b06fad5bdb7e0
mistune==0.8.4 \
--hash=sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e \
--hash=sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4
Expand Down Expand Up @@ -491,6 +495,8 @@ python-editor==1.0.4 \
pytz==2019.1 \
--hash=sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda \
--hash=sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141
PyYAML==3.12 \
--hash=sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab
raven==6.10.0 \
--hash=sha256:3fa6de6efa2493a7c827472e984ce9b020797d0da16f1db67197bcc23c8fae54 \
--hash=sha256:44a13f87670836e153951af9a3c80405d36b43097db869a36e92809673692ce4
Expand Down
1 change: 1 addition & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ def __init__(self):
]
assert configurator_obj.include.calls == (
[
pretend.call(".api")
pretend.call("pyramid_services"),
pretend.call(".metrics"),
pretend.call(".csrf"),
Expand Down
15 changes: 15 additions & 0 deletions warehouse/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


def includeme(config):
config.include(".routes")
92 changes: 92 additions & 0 deletions warehouse/api/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


def includeme(config):
# Add a subdomain for the hypermedia api.
hypermedia = config.get_settings().get("hypermedia.domain")

config.add_route("api.spec", "/api/", read_only=True, domain=hypermedia)
config.add_route(
"api.views.projects",
"/api/projects/",
factory="warehouse.packaging.models:ProjectFactory",
read_only=True,
domain=hypermedia,
)
config.add_route(
"api.views.projects.detail",
"/api/projects/{name}/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{name}",
read_only=True,
domain=hypermedia,
)
config.add_route(
"api.views.projects.detail.files",
"/api/projects/{name}/files/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{name}",
read_only=True,
domain=hypermedia,
)
config.add_route(
"api.views.projects.releases",
"/api/projects/{name}/releases/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{name}",
read_only=True,
domain=hypermedia,
)
config.add_route(
"api.views.projects.releases.detail",
"/api/projects/{name}/releases/{version}/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{name}/{version}",
read_only=True,
domain=hypermedia,
)
config.add_route(
"api.views.projects.releases.files",
"/api/projects/{name}/releases/{version}/files/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{name}/{version}",
read_only=True,
domain=hypermedia,
)
config.add_route(
"api.views.projects.detail.roles",
"/api/projects/{name}/roles/",
factory="warehouse.packaging.models:ProjectFactory",
traverse="/{name}",
read_only=True,
domain=hypermedia,
)
config.add_route(
"api.views.journals", "/api/journals/", read_only=True, domain=hypermedia
)
# This is the JSON API equivalent of changelog_last_serial()
config.add_route(
"api.views.journals.latest",
"/api/journals/latest/",
read_only=True,
domain=hypermedia,
)
# This is the JSON API equivalent of user_packages(user)
config.add_route(
"api.views.users.details.projects",
"/api/users/{user}/projects/",
factory="warehouse.accounts.models:UserFactory",
traverse="/{user}",
read_only=True,
domain=hypermedia,
)
195 changes: 195 additions & 0 deletions warehouse/api/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import datetime

from marshmallow import Schema, fields


class File(Schema):
filename = fields.Str()
packagetype = fields.Str()
python_version = fields.Str()
has_sig = fields.Bool(attribute="has_signature")
comment_text = fields.Str()
md5_digest = fields.Str()
digests = fields.Method("get_digests")
size = fields.Int()
upload_time = fields.Function(
lambda obj: obj.upload_time.strftime("%Y-%m-%dT%H:%M:%S")
)
url = fields.Method("get_detail_url")

def get_digests(self, obj):
return {"md5": obj.md5_digest, "sha256": obj.sha256_digest}

def get_detail_url(self, obj):
request = self.context.get("request")
return request.route_url("packaging.file", path=obj.path)


class Release(Schema):
bugtrack_url = fields.Str(attribute="project.bugtrack_url")
classifiers = fields.List(fields.Str())
docs_url = fields.Str(attribute="project.documentation_url")
downloads = fields.Method("get_downloads")
project_url = fields.Method("get_project_url")
url = fields.Method("get_release_url")
requires_dist = fields.List(fields.Str())
files_url = fields.Method("get_files_url")

def get_files_url(self, obj):
request = self.context.get("request")
return request.route_url(
"api.views.projects.releases.files",
name=obj.project.name,
version=obj.version,
)

def get_project_url(self, obj):
request = self.context.get("request")
return request.route_url("api.views.projects.detail", name=obj.project.name)

def get_release_url(self, obj):
request = self.context.get("request")
return request.route_url(
"api.views.projects.releases.detail",
name=obj.project.name,
version=obj.version,
)

def get_downloads(self, obj):
return {"last_day": -1, "last_week": -1, "last_month": -1}

class Meta:
fields = (
"author",
"author_email",
"bugtrack_url",
"classifiers",
"description",
"description_content_type",
"docs_url",
"downloads",
"download_url",
"home_page",
"keywords",
"license",
"maintainer",
"maintainer_email",
"name",
"project_url",
"url",
"platform",
"requires_dist",
"requires_python",
"summary",
"version",
"files_url",
)
ordered = True


class Project(Schema):
url = fields.Method("get_detail_url")
releases_url = fields.Method("get_releases_url")
latest_version_url = fields.Method("get_latest_version_url")
legacy_project_json = fields.Method("get_legacy_project_json")
roles_url = fields.Method("get_roles_url")
files_url = fields.Method("get_files_url")

def get_files_url(self, obj):
request = self.context.get("request")
return request.route_url("api.views.projects.detail.files", name=obj.name)

def get_roles_url(self, obj):
request = self.context.get("request")
return request.route_url(
"api.views.projects.detail.roles", name=obj.normalized_name
)

def get_legacy_project_json(self, obj):
request = self.context.get("request")
return request.route_url("legacy.api.json.project", name=obj.normalized_name)

def get_detail_url(self, obj):
request = self.context.get("request")
return request.route_url("api.views.projects.detail", name=obj.normalized_name)

def get_latest_version_url(self, obj):
request = self.context.get("request")
if not obj.latest_version:
return None
return request.route_url(
"api.views.projects.releases.detail",
name=obj.name,
version=obj.latest_version[0],
)

def get_releases_url(self, obj):
request = self.context.get("request")
return request.route_url(
"api.views.projects.releases", name=obj.normalized_name
)

class Meta:
fields = (
"name",
"normalized_name",
"latest_version_url",
"bugtrack_url",
"last_serial",
"url",
"releases_url",
"legacy_project_json",
"stable_version",
"created",
"roles_url",
"files_url",
)


class Journal(Schema):
project_name = fields.Str(attribute="name")
timestamp = fields.Method("get_timestamp")
release_url = fields.Method("get_release_url")

def get_release_url(self, obj):
request = self.context.get("request")
if not obj.version:
return None
return request.route_url(
"api.views.projects.releases.detail", name=obj.name, version=obj.version
)

def get_timestamp(self, obj):
return int(obj.submitted_date.replace(tzinfo=datetime.timezone.utc).timestamp())

class Meta:
fields = (
"project_name",
"release_url",
"version",
"timestamp",
"action",
"submitted_date",
)


class Role(Schema):
role = fields.Str(attribute="role_name")
name = fields.Str(attribute="user.username")


class UserProjects(Schema):
role = fields.Str(attribute="role_name")
project = fields.Nested(Project(only=("name", "url")))
Loading

0 comments on commit c4b8ce3

Please sign in to comment.