diff --git a/dev/environment b/dev/environment index 1f2a8a2ae1b0..710800697823 100644 --- a/dev/environment +++ b/dev/environment @@ -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 diff --git a/requirements/main.in b/requirements/main.in index 1f1373b8a432..d3a84b9e4456 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 @@ -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 diff --git a/requirements/main.txt b/requirements/main.txt index 9ef2b0ed702e..81de943b44d6 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -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 \ @@ -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 @@ -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 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 59ea968a4613..0d95248fb755 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -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"), 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 b928d52a6c49..33d1d536975d 100644 --- a/warehouse/config.py +++ b/warehouse/config.py @@ -149,6 +149,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") @@ -240,6 +241,9 @@ def configure(settings=None): # Register metrics config.include(".metrics") + # Register Hypermedia API + config.include(".api") + # Register our CSRF support. We do this here, immediately after we've # created the Configurator instance so that we ensure to get our defaults # set ASAP before anything else has a chance to set them and possibly call