diff --git a/dev/environment b/dev/environment index cfb666cef048..caa2a01d0a14 100644 --- a/dev/environment +++ b/dev/environment @@ -33,3 +33,5 @@ TOKEN_EMAIL_SECRET="an insecure email verification secret key" DATADOG_HOST=notdatadog WAREHOUSE_LEGACY_DOMAIN=pypi.python.org + +HYPERMEDIA_API=api.pypi.org diff --git a/requirements/main.in b/requirements/main.in index e1fcebaf89e0..7d615437c2da 100644 --- a/requirements/main.in +++ b/requirements/main.in @@ -1,4 +1,5 @@ alembic>=0.7.0 +apispec Automat argon2-cffi Babel @@ -21,6 +22,7 @@ itsdangerous Jinja2>=2.8 limits lxml +marshmallow mistune msgpack packaging>=15.2 @@ -36,6 +38,7 @@ pyramid_retry>=0.3 pyramid_rpc>=0.7 pyramid_services pyramid_tm>=0.12 +PyYaml raven readme_renderer>=0.7.0 requests diff --git a/requirements/main.txt b/requirements/main.txt index fa5b486c8c4a..59606c8dffa9 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -3,6 +3,8 @@ alembic==0.9.9 \ amqp==2.2.2 \ --hash=sha256:4e28d3ea61a64ae61830000c909662cb053642efddbe96503db0e7783a6ee85b \ --hash=sha256:cba1ace9d4ff6049b190d8b7991f9c1006b443a5238021aca96dd6ad2ac9da22 +apispec==0.37.0 \ + --hash=sha256:8c497b70f0095da521b41ea1e85cd171302b80e4bb6382cb0055cadf4980ced1 argon2-cffi==18.1.0 \ --hash=sha256:93f631fa567dbf948f26874476c9e9afb51e0a835372bf1a319df0c5aa071bfb \ --hash=sha256:131effd5eabbe08649bc672b5d602fd6e2772b03cfec2ddb2795f9d9babe3fba \ @@ -269,6 +271,8 @@ Mako==1.0.7 \ --hash=sha256:4e02fde57bd4abb5ec400181e4c314f56ac3e49ba4fb8b0d50bba18cb27d25ae MarkupSafe==1.0 \ --hash=sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665 +marshmallow==3.0.0b10 \ + --hash=sha256:be2541dfd0fe7fdbb6ab83ab187e5190dfe2e169b68bb6ff982b06fad5bdb7e0 mistune==0.8.3 \ --hash=sha256:b4c512ce2fc99e5a62eb95a4aba4b73e5f90264115c40b70a21e1f7d4e0eac91 \ --hash=sha256:bc10c33bfdcaa4e749b779f62f60d6e12f8215c46a292d05e486b869ae306619 @@ -420,6 +424,8 @@ python-editor==1.0.3 \ pytz==2018.4 \ --hash=sha256:65ae0c8101309c45772196b21b74c46b2e5d11b6275c45d251b150d5da334555 \ --hash=sha256:c06425302f2cf668f1bba7a0a03f3c1d34d4ebeef2c72003da308b3947c7f749 +PyYAML==3.12 \ + --hash=sha256:592766c6303207a20efc445587778322d7f73b161bd994f227adaa341ba212ab raven==6.8.0 \ --hash=sha256:1c641e5ebc2d4185560608e253970ca0d4b98475f4edf67735015a415f9e1d48 \ --hash=sha256:95aecf76c414facaddbb056f3e98c7936318123e467728f2e50b3a66b65a6ef7 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 2387a772347f..336cd32bf424 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -327,7 +327,7 @@ def __init__(self): pretend.call(HostRewrite), ] assert configurator_obj.include.calls == ( - [pretend.call(".datadog"), pretend.call(".csrf")] + [pretend.call(".api"), pretend.call(".datadog"), pretend.call(".csrf")] + [ pretend.call(x) for x in [ diff --git a/warehouse/api/__init__.py b/warehouse/api/__init__.py new file mode 100644 index 000000000000..6604331f9de2 --- /dev/null +++ b/warehouse/api/__init__.py @@ -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") diff --git a/warehouse/api/routes.py b/warehouse/api/routes.py new file mode 100644 index 000000000000..2da1a661ac3d --- /dev/null +++ b/warehouse/api/routes.py @@ -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, + ) diff --git a/warehouse/api/schema.py b/warehouse/api/schema.py new file mode 100644 index 000000000000..4ade2c0870a9 --- /dev/null +++ b/warehouse/api/schema.py @@ -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"))) diff --git a/warehouse/api/spec.py b/warehouse/api/spec.py new file mode 100644 index 000000000000..1b734d6c434f --- /dev/null +++ b/warehouse/api/spec.py @@ -0,0 +1,108 @@ +# 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. + +from apispec import APISpec + +from warehouse.api import schema + +hypermedia_spec = APISpec( + title="Hypermedia API", + version="0.0.0", + info=dict(description="A resource based hypermedia API"), + plugins=["apispec.ext.marshmallow"], +) +hypermedia_spec.definition("project", schema=schema.Project) +hypermedia_spec.definition("release", schema=schema.Release) +hypermedia_spec.definition("journal", schema=schema.Journal) +hypermedia_spec.definition("roles", schema=schema.Role) + +hypermedia_spec.add_path( + path="/projects/", + operations=dict( + get=dict( + description="Return a paginated list of all projects", + responses={"200": {"schema": {"$ref": "#/definitions/project"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/projects/{name}/", + operations=dict( + get=dict( + description="Return details of a specific project", + responses={"200": {"schema": {"$ref": "#/definitions/project"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/projects/{name}/releases/", + operations=dict( + get=dict( + description="Return a list of all releases of a project", + responses={"200": {"schema": {"$ref": "#/definitions/release"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/projects/{name}/releases/{version}/", + operations=dict( + get=dict( + decription="Return a single version of a project", + responses={"200": {"schema": {"$ref": "#/definitions/release"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/projects/{name}/releases/{version}/files/", + operations=dict( + get=dict( + decription="Returns files of this version of the project", + responses={"200": {"schema": {"$ref": "#/definitions/release"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/projects/{name}/roles/", + operations=dict( + get=dict( + description="Return a list of user roles for this project", + responses={"200": {"schema": {"$ref": "#/definitions/roles"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/journals/", + operations=dict( + get=dict( + description="Return a paginated list of all changes", + responses={"200": {"schema": {"$ref": "#/definitions/journal"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/journals/latest/", + operations=dict( + get=dict( + description="Return the id of most recent change", + responses={"200": {"schema": {"$ref": "#/definitions/journal"}}}, + ) + ), +) +hypermedia_spec.add_path( + path="/users/{user}/projects/", + operations=dict( + get=dict( + description="Return the projects of a specific user", + responses={"200": {"schema": {"$ref": "#/definitions/project"}}}, + ) + ), +) diff --git a/warehouse/api/utils.py b/warehouse/api/utils.py new file mode 100644 index 000000000000..89fa9dfb20ea --- /dev/null +++ b/warehouse/api/utils.py @@ -0,0 +1,38 @@ +# 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 pagination_serializer(schema, data, route, request): + extra_filters = "" + for key, value in request.params.items(): + if key != "page": + extra_filters = "{filters}&{key}={value}".format( + filters=extra_filters, key=key, value=value + ) + resource_url = request.route_url(route) + url_template = "{url}?page={page}{extra_filters}" + + next_page = None + if data.next_page: + next_page = url_template.format( + url=resource_url, page=data.next_page, extra_filters=extra_filters + ) + previous_page = None + if data.previous_page: + previous_page = url_template.format( + url=resource_url, page=data.previous_page, extra_filters=extra_filters + ) + + return { + "data": schema.dump(data), + "links": {"next_page": next_page, "previous_page": previous_page}, + } diff --git a/warehouse/api/views.py b/warehouse/api/views.py new file mode 100644 index 000000000000..d78888e2edec --- /dev/null +++ b/warehouse/api/views.py @@ -0,0 +1,243 @@ +# 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 packaging.version import parse + +from paginate_sqlalchemy import SqlalchemyOrmPage as SQLAlchemyORMPage +from pyramid.view import view_config +from sqlalchemy import func, orm + +from warehouse.packaging import models +from warehouse.utils.paginate import paginate_url_factory +from warehouse.api import schema +from warehouse.api import spec +from warehouse.api.utils import pagination_serializer + +# Should this move to a config? +ITEMS_PER_PAGE = 100 + + +@view_config(route_name="api.spec", renderer="json") +def api_spec(request): + return spec.hypermedia_spec.to_dict() + + +@view_config(route_name="api.views.projects", renderer="json") +def projects(request): + """ + Return a paginated list of all projects, serialized minimally. + + Replaces simple API: /simple/ + Replaces XML-RPC: list_packages() + + Filters: + serial_since: Limits the response to projects that have been updated + since the provided serial. + page_num: Specifies the page to start with. If not provided, the + response begins at page 1. + """ + serial_since = request.params.get("serial_since") + serial = request.params.get("serial") + + page_num = int(request.params.get("page", 1)) + query = request.db.query(models.Project).order_by(models.Project.created) + + if serial_since: + query = query.filter(models.Project.last_serial >= serial_since) + if serial: + query = query.filter(models.Project.last_serial == serial) + + projects_page = SQLAlchemyORMPage( + query, + page=page_num, + items_per_page=ITEMS_PER_PAGE, + url_maker=paginate_url_factory(request), + ) + project_schema = schema.Project( + only=("last_serial", "normalized_name", "url", "legacy_project_json"), many=True + ) + project_schema.context = {"request": request} + return pagination_serializer( + project_schema, projects_page, "api.views.projects", request + ) + + +@view_config( + route_name="api.views.projects.detail", renderer="json", context=models.Project +) +def projects_detail(project, request): + """ + Returns a detail view of a single project. + """ + project_schema = schema.Project() + project_schema.context = {"request": request} + return project_schema.dump(project) + + +@view_config( + route_name="api.views.projects.detail.files", + renderer="json", + context=models.Project, +) +def projects_detail_files(project, request): + files = sorted( + request.db.query(models.File) + .options(orm.joinedload(models.File.release)) + .filter( + models.File.name == project.name, + models.File.version.in_( + request.db.query(models.Release) + .filter(models.Release.project == project) + .with_entities(models.Release.version) + ), + ) + .all(), + key=lambda f: (parse(f.version), f.filename), + ) + serializer = schema.File(many=True, only=("filename", "url")) + serializer.context = {"request": request} + return serializer.dump(files) + + +@view_config( + route_name="api.views.projects.releases", renderer="json", context=models.Project +) +def project_releases(project, request): + releases = ( + request.db.query(models.Release) + .filter(models.Release.project == project) + .order_by( + models.Release.is_prerelease.nullslast(), + models.Release._pypi_ordering.desc(), + ) + .all() + ) + serializer = schema.Release(many=True, only=("version", "url")) + serializer.context = {"request": request} + return serializer.dump(releases) + + +@view_config(route_name="api.views.projects.releases.detail", renderer="json") +def releases_detail(release, request): + + project = release.project + try: + release = ( + request.db.query(models.Release) + .options(orm.undefer("description")) + .join(models.Project) + .filter( + ( + models.Project.normalized_name + == func.normalize_pep426_name(project.name) + ) + & (models.Release.version == release.version) + ) + .one() + ) + except orm.exc.NoResultFound: + return {} + serializer = schema.Release() + serializer.context = {"request": request} + return serializer.dump(release) + + +@view_config(route_name="api.views.projects.releases.files", renderer="json") +def releases_detail_files(release, request): + + project = release.project + files = ( + request.db.query(models.File) + .join(models.Release) + .join(models.Project) + .filter( + (models.Project.normalized_name == func.normalize_pep426_name(project.name)) + ) + .order_by(models.Release._pypi_ordering.desc(), models.File.filename) + .all() + ) + serializer = schema.File(many=True) + serializer.context = {"request": request} + return serializer.dump(files) + + +@view_config(route_name="api.views.projects.detail.roles", renderer="json") +def projects_detail_roles(project, request): + roles = ( + request.db.query(models.Role) + .join(models.User, models.Project) + .filter( + models.Project.normalized_name == func.normalize_pep426_name(project.name) + ) + .order_by(models.Role.role_name.desc(), models.User.username) + .all() + ) + serializer = schema.Role(many=True) + return serializer.dump(roles) + + +@view_config(route_name="api.views.journals", renderer="json") +def journals(request): + since = request.params.get("since") + updated_releases = request.params.get("updated_releases") + page_num = int(request.params.get("page", 1)) + query = request.db.query(models.JournalEntry).order_by( + models.JournalEntry.submitted_date + ) + + if updated_releases: + query = query.filter(models.JournalEntry.version.isnot(None)) + + if since: + query = query.filter( + models.JournalEntry.submitted_date + > datetime.datetime.utcfromtimestamp(int(since)) + ) + + journals_page = SQLAlchemyORMPage( + query, + page=page_num, + items_per_page=ITEMS_PER_PAGE, + url_maker=paginate_url_factory(request), + ) + serializer = schema.Journal(many=True) + serializer.context = {"request": request} + return pagination_serializer( + serializer, journals_page, "api.views.journals", request + ) + + +@view_config(route_name="api.views.journals.latest", renderer="json") +def journals_latest(request): + last_serial = request.db.query(func.max(models.JournalEntry.id)).scalar() + response = { + "last_serial": last_serial, + "project_url": request.route_url( + "api.views.projects", _query={"serial": last_serial} + ), + } + return response + + +@view_config(route_name="api.views.users.details.projects", renderer="json") +def user_detail_packages(user, request): + roles = ( + request.db.query(models.Role) + .join(models.User, models.Project) + .filter(models.User.username == user.username) + .order_by(models.Role.role_name.desc(), models.Project.name) + .all() + ) + serializer = schema.UserProjects(many=True) + serializer.context["request"] = request + return serializer.dump(roles) diff --git a/warehouse/config.py b/warehouse/config.py index c5447e9514ff..f64679cc8702 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -181,6 +181,7 @@ def configure(settings=None): maybe_set(settings, "warehouse.num_proxies", "WAREHOUSE_NUM_PROXIES", int) maybe_set(settings, "warehouse.theme", "WAREHOUSE_THEME") maybe_set(settings, "warehouse.domain", "WAREHOUSE_DOMAIN") + maybe_set(settings, "hypermedia.domain", "HYPERMEDIA_DOMAIN") maybe_set(settings, "forklift.domain", "FORKLIFT_DOMAIN") maybe_set(settings, "warehouse.legacy_domain", "WAREHOUSE_LEGACY_DOMAIN") maybe_set(settings, "site.name", "SITE_NAME", default="Warehouse") @@ -262,6 +263,9 @@ def configure(settings=None): ) config.add_tween("warehouse.config.unicode_redirect_tween_factory") + # Register Hypermedia API + config.include(".api") + # Register DataDog metrics config.include(".datadog")