diff --git a/mypy.ini b/mypy.ini index 5a77a9c0..e4ae5aea 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,49 +1,52 @@ [mypy] namespace_packages = True warn_unused_ignores = True # support for django magic: https://github.com/typeddjango/django-stubs plugins = mypy_django_plugin.main [mypy.plugins.django-stubs] django_settings_module = swh.web.settings.development # 3rd party libraries without stubs (yet) [mypy-bs4.*] ignore_missing_imports = True +[mypy-corsheaders.*] +ignore_missing_imports = True + [mypy-django_js_reverse.*] ignore_missing_imports = True [mypy-htmlmin.*] ignore_missing_imports = True [mypy-magic.*] ignore_missing_imports = True [mypy-pkg_resources.*] ignore_missing_imports = True [mypy-pygments.*] ignore_missing_imports = True [mypy-pypandoc.*] ignore_missing_imports = True [mypy-pytest.*] ignore_missing_imports = True [mypy-rest_framework.*] ignore_missing_imports = True [mypy-requests_mock.*] ignore_missing_imports = True [mypy-sphinx.*] ignore_missing_imports = True [mypy-sphinxcontrib.*] ignore_missing_imports = True [mypy-swh.docs.*] ignore_missing_imports = True diff --git a/requirements.txt b/requirements.txt index 2535a552..e6408273 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,26 +1,28 @@ # Add here external Python modules dependencies, one per line. Module names # should match https://pypi.python.org/pypi names. For the full spec or # dependency lines, see https://pip.readthedocs.org/en/1.1/requirements.html # Runtime dependencies beautifulsoup4 Django >= 1.11.0, < 2.0 +django-cors-headers djangorestframework >= 3.4.0 django_webpack_loader django_js_reverse docutils python-magic >= 0.4.0 htmlmin lxml pygments pypandoc python-dateutil pyyaml requests python-memcached +pybadges # Doc dependencies sphinx sphinxcontrib-httpdomain diff --git a/swh/web/misc/badges.py b/swh/web/misc/badges.py new file mode 100644 index 00000000..040b3ca4 --- /dev/null +++ b/swh/web/misc/badges.py @@ -0,0 +1,173 @@ +# Copyright (C) 2019 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from base64 import b64encode +from typing import cast, Optional + +from django.conf.urls import url +from django.contrib.staticfiles import finders +from django.http import HttpResponse, HttpRequest + +from pybadges import badge + +from swh.model.exceptions import ValidationError +from swh.model.identifiers import ( + persistent_identifier, parse_persistent_identifier, + CONTENT, DIRECTORY, ORIGIN, RELEASE, REVISION, SNAPSHOT +) +from swh.web.common import service +from swh.web.common.exc import BadInputExc, NotFoundExc +from swh.web.common.utils import reverse, resolve_swh_persistent_id + + +_orange = '#f36a24' +_yellow = '#fac11f' +_red = '#cd5741' + +_swh_logo_data = None + +_badge_config = { + CONTENT: { + 'color': _yellow, + 'title': 'Archived source file', + }, + DIRECTORY: { + 'color': _yellow, + 'title': 'Archived source tree', + }, + ORIGIN: { + 'color': _orange, + 'title': 'Archived software repository', + }, + RELEASE: { + 'color': _yellow, + 'title': 'Archived software release', + }, + REVISION: { + 'color': _yellow, + 'title': 'Archived commit', + }, + SNAPSHOT: { + 'color': _yellow, + 'title': 'Archived software repository snapshot', + }, + 'error': { + 'color': _red, + 'title': 'An error occurred when generating the badge' + } +} + + +def _get_logo_data() -> str: + """ + Get data-URI for Software Heritage SVG logo to embed it in + the generated badges. + """ + global _swh_logo_data + if _swh_logo_data is None: + swh_logo_path = cast(str, finders.find('img/swh-logo-white.svg')) + with open(swh_logo_path, 'rb') as swh_logo_file: + _swh_logo_data = ('data:image/svg+xml;base64,%s' % + b64encode(swh_logo_file.read()).decode('ascii')) + return _swh_logo_data + + +def _swh_badge(request: HttpRequest, object_type: str, object_id: str, + object_pid: Optional[str] = '') -> HttpResponse: + """ + Generate a Software Heritage badge for a given object type and id. + + Args: + request: input http request + object_type: The type of swh object to generate a badge for, + either *content*, *directory*, *revision*, *release*, *origin* + or *snapshot* + object_id: The id of the swh object, either an url for origin + type or a *sha1* for other object types + object_pid: If provided, the object persistent + identifier will not be recomputed + + Returns: + HTTP response with content type *image/svg+xml* containing the SVG + badge data. If the provided parameters are invalid, HTTP 400 status + code will be returned. If the object can not be found in the archive, + HTTP 404 status code will be returned. + + """ + left_text = 'error' + whole_link = '' + status = 200 + + try: + if object_type == ORIGIN: + service.lookup_origin({'url': object_id}) + right_text = 'repository' + whole_link = reverse('browse-origin', + url_args={'origin_url': object_id}) + else: + # when pid is provided, object type and id will be parsed + # from it + if object_pid: + parsed_pid = parse_persistent_identifier(object_pid) + object_type = parsed_pid.object_type + object_id = parsed_pid.object_id + swh_object = service.lookup_object(object_type, object_id) + if object_pid: + right_text = object_pid + else: + right_text = persistent_identifier(object_type, object_id) + + whole_link = resolve_swh_persistent_id(right_text)['browse_url'] + # remove pid metadata if any for badge text + if object_pid: + right_text = right_text.split(';')[0] + # use release name for badge text + if object_type == RELEASE: + right_text = 'release %s' % swh_object['name'] + left_text = 'archived' + except (BadInputExc, ValidationError): + right_text = f'invalid {object_type if object_type else "object"} id' + status = 400 + object_type = 'error' + except NotFoundExc: + right_text = f'{object_type if object_type else "object"} not found' + status = 404 + object_type = 'error' + + badge_data = badge(left_text=left_text, + right_text=right_text, + right_color=_badge_config[object_type]['color'], + whole_link=request.build_absolute_uri(whole_link), + whole_title=_badge_config[object_type]['title'], + logo=_get_logo_data(), + embed_logo=True) + + return HttpResponse(badge_data, content_type='image/svg+xml', + status=status) + + +def _swh_badge_pid(request: HttpRequest, object_pid: str) -> HttpResponse: + """ + Generate a Software Heritage badge for a given object persistent + identifier. + + Args: + request (django.http.HttpRequest): input http request + object_pid (str): A swh object persistent identifier + + Returns: + django.http.HttpResponse: An http response with content type + *image/svg+xml* containing the SVG badge data. If any error + occurs, a status code of 400 will be returned. + """ + return _swh_badge(request, '', '', object_pid) + + +urlpatterns = [ + url(r'^badge/(?P[a-z]+)/(?P.+)/$', _swh_badge, + name='swh-badge'), + url(r'^badge/(?Pswh:[0-9]+:[a-z]+:[0-9a-f]+.*)/$', + _swh_badge_pid, name='swh-badge-pid'), +] diff --git a/swh/web/misc/urls.py b/swh/web/misc/urls.py index 46afffe8..602a74aa 100644 --- a/swh/web/misc/urls.py +++ b/swh/web/misc/urls.py @@ -1,78 +1,79 @@ # Copyright (C) 2019 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import json import requests from django.conf.urls import url, include from django.contrib.staticfiles import finders from django.http import HttpResponse from django.shortcuts import render from swh.web.common import service from swh.web.config import get_config def _jslicenses(request): jslicenses_file = finders.find('jssources/jslicenses.json') jslicenses_data = json.load(open(jslicenses_file)) jslicenses_data = sorted(jslicenses_data.items(), key=lambda item: item[0].split('/')[-1]) return render(request, "misc/jslicenses.html", {'jslicenses_data': jslicenses_data}) def _stat_counters(request): stat = service.stat_counters() url = get_config()['history_counters_url'] stat_counters_history = 'null' if url: try: response = requests.get(url, timeout=5) stat_counters_history = response.text except Exception: pass json_data = '{"stat_counters": %s, "stat_counters_history": %s}' % ( json.dumps(stat), stat_counters_history) return HttpResponse(json_data, content_type='application/json') urlpatterns = [ url(r'^', include('swh.web.misc.coverage')), url(r'^jslicenses/$', _jslicenses, name='jslicenses'), url(r'^', include('swh.web.misc.origin_save')), url(r'^stat_counters', _stat_counters, name='stat-counters'), + url(r'^', include('swh.web.misc.badges')), ] # when running end to end tests trough cypress, declare some extra # endpoints to provide input data for some of those tests if get_config()['e2e_tests_mode']: from swh.web.tests.data import ( get_content_code_data_by_ext, get_content_other_data_by_ext, get_content_code_data_all_exts, get_content_code_data_by_filename, get_content_code_data_all_filenames, ) # noqa urlpatterns.append( url(r'^tests/data/content/code/extension/(?P.+)/$', get_content_code_data_by_ext, name='tests-content-code-extension')) urlpatterns.append( url(r'^tests/data/content/other/extension/(?P.+)/$', get_content_other_data_by_ext, name='tests-content-other-extension')) urlpatterns.append(url(r'^tests/data/content/code/extensions/$', get_content_code_data_all_exts, name='tests-content-code-extensions')) urlpatterns.append( url(r'^tests/data/content/code/filename/(?P.+)/$', get_content_code_data_by_filename, name='tests-content-code-filename')) urlpatterns.append(url(r'^tests/data/content/code/filenames/$', get_content_code_data_all_filenames, name='tests-content-code-filenames')) diff --git a/swh/web/settings/common.py b/swh/web/settings/common.py index e0fdea50..0f7f8654 100644 --- a/swh/web/settings/common.py +++ b/swh/web/settings/common.py @@ -1,279 +1,284 @@ # Copyright (C) 2017-2019 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information """ Django common settings for swh-web. """ import os from swh.web.config import get_config swh_web_config = get_config() # Build paths inside the project like this: os.path.join(BASE_DIR, ...) PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = swh_web_config['secret_key'] # SECURITY WARNING: don't run with debug turned on in production! DEBUG = swh_web_config['debug'] DEBUG_PROPAGATE_EXCEPTIONS = swh_web_config['debug'] ALLOWED_HOSTS = ['127.0.0.1', 'localhost'] + swh_web_config['allowed_hosts'] # Application definition INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', 'swh.web.common', 'swh.web.api', 'swh.web.browse', 'webpack_loader', - 'django_js_reverse' + 'django_js_reverse', + 'corsheaders' ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'swh.web.common.middlewares.ThrottlingHeadersMiddleware' ] # Compress all assets (static ones and dynamically generated html) # served by django in a local development environment context. # In a production environment, assets compression will be directly # handled by web servers like apache or nginx. if swh_web_config['serve_assets']: MIDDLEWARE.insert(0, 'django.middleware.gzip.GZipMiddleware') ROOT_URLCONF = 'swh.web.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [os.path.join(PROJECT_DIR, "../templates")], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', 'swh.web.common.utils.context_processor' ], 'libraries': { 'swh_templatetags': 'swh.web.common.swh_templatetags', }, }, }, ] DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': swh_web_config['development_db'], } } # Password validation # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # noqa }, { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # noqa }, { 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # noqa }, { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # noqa }, ] # Internationalization # https://docs.djangoproject.com/en/1.11/topics/i18n/ LANGUAGE_CODE = 'en-us' TIME_ZONE = 'UTC' USE_I18N = True USE_L10N = True USE_TZ = True # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.11/howto/static-files/ STATIC_URL = '/static/' STATICFILES_DIRS = [ os.path.join(PROJECT_DIR, "../static") ] INTERNAL_IPS = ['127.0.0.1'] throttle_rates = {} http_requests = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'] throttling = swh_web_config['throttling'] for limiter_scope, limiter_conf in throttling['scopes'].items(): if 'default' in limiter_conf['limiter_rate']: throttle_rates[limiter_scope] = limiter_conf['limiter_rate']['default'] # for backward compatibility else: throttle_rates[limiter_scope] = limiter_conf['limiter_rate'] # register sub scopes specific for HTTP request types for http_request in http_requests: if http_request in limiter_conf['limiter_rate']: throttle_rates[limiter_scope + '_' + http_request.lower()] = \ limiter_conf['limiter_rate'][http_request] REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', 'swh.web.api.renderers.YAMLRenderer', 'rest_framework.renderers.TemplateHTMLRenderer' ), 'DEFAULT_THROTTLE_CLASSES': ( 'swh.web.common.throttling.SwhWebRateThrottle', ), 'DEFAULT_THROTTLE_RATES': throttle_rates } LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'filters': { 'require_debug_false': { '()': 'django.utils.log.RequireDebugFalse', }, 'require_debug_true': { '()': 'django.utils.log.RequireDebugTrue', }, }, 'formatters': { 'request': { 'format': '[%(asctime)s] [%(levelname)s] %(request)s %(status_code)s', # noqa 'datefmt': "%d/%b/%Y %H:%M:%S" }, 'simple': { 'format': '[%(asctime)s] [%(levelname)s] %(message)s', 'datefmt': "%d/%b/%Y %H:%M:%S" }, 'verbose': { 'format': '[%(asctime)s] [%(levelname)s] %(name)s.%(funcName)s:%(lineno)s - %(message)s', # noqa 'datefmt': "%d/%b/%Y %H:%M:%S" }, }, 'handlers': { 'console': { 'level': 'DEBUG', 'filters': ['require_debug_true'], 'class': 'logging.StreamHandler', 'formatter': 'simple' }, 'file': { 'level': 'WARNING', 'filters': ['require_debug_false'], 'class': 'logging.FileHandler', 'filename': os.path.join(swh_web_config['log_dir'], 'swh-web.log'), 'formatter': 'simple' }, 'file_request': { 'level': 'WARNING', 'filters': ['require_debug_false'], 'class': 'logging.FileHandler', 'filename': os.path.join(swh_web_config['log_dir'], 'swh-web.log'), 'formatter': 'request' }, 'console_verbose': { 'level': 'DEBUG', 'filters': ['require_debug_true'], 'class': 'logging.StreamHandler', 'formatter': 'verbose' }, 'file_verbose': { 'level': 'WARNING', 'filters': ['require_debug_false'], 'class': 'logging.FileHandler', 'filename': os.path.join(swh_web_config['log_dir'], 'swh-web.log'), 'formatter': 'verbose' }, 'null': { 'class': 'logging.NullHandler', }, }, 'loggers': { '': { 'handlers': ['console_verbose', 'file_verbose'], 'level': 'DEBUG' if DEBUG else 'WARNING', }, 'django': { 'handlers': ['console'], 'level': 'DEBUG' if DEBUG else 'WARNING', 'propagate': False, }, 'django.request': { 'handlers': ['file_request'], 'level': 'DEBUG' if DEBUG else 'WARNING', 'propagate': False, }, 'django.db.backends': { 'handlers': ['null'], 'propagate': False }, }, } WEBPACK_LOADER = { # noqa 'DEFAULT': { 'CACHE': False, 'BUNDLE_DIR_NAME': './', 'STATS_FILE': os.path.join(PROJECT_DIR, '../static/webpack-stats.json'), # noqa 'POLL_INTERVAL': 0.1, 'TIMEOUT': None, 'IGNORE': ['.+\.hot-update.js', '.+\.map'] } } LOGIN_URL = '/admin/login/' LOGIN_REDIRECT_URL = 'admin' SESSION_ENGINE = 'django.contrib.sessions.backends.cache' CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache' }, 'db_cache': { 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 'LOCATION': 'swh_web_cache', } } JS_REVERSE_JS_MINIFY = False + +CORS_ORIGIN_ALLOW_ALL = True +CORS_URLS_REGEX = r'^/badge/.*$' diff --git a/swh/web/static/img/swh-logo-white.svg b/swh/web/static/img/swh-logo-white.svg new file mode 100644 index 00000000..f88d6bf5 --- /dev/null +++ b/swh/web/static/img/swh-logo-white.svg @@ -0,0 +1,237 @@ + + + +image/svg+xml + + + + + + + + + + \ No newline at end of file diff --git a/swh/web/tests/misc/test_badges.py b/swh/web/tests/misc/test_badges.py new file mode 100644 index 00000000..251e7c3b --- /dev/null +++ b/swh/web/tests/misc/test_badges.py @@ -0,0 +1,166 @@ +# Copyright (C) 2019 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from corsheaders.middleware import ACCESS_CONTROL_ALLOW_ORIGIN +from hypothesis import given + +from swh.model.identifiers import ( + persistent_identifier, + CONTENT, DIRECTORY, ORIGIN, RELEASE, REVISION, SNAPSHOT +) +from swh.web.common import service +from swh.web.common.utils import reverse, resolve_swh_persistent_id +from swh.web.misc.badges import _badge_config, _get_logo_data +from swh.web.tests.django_asserts import assert_contains +from swh.web.tests.strategies import ( + content, directory, origin, release, revision, snapshot, + unknown_content, unknown_directory, new_origin, unknown_release, + unknown_revision, unknown_snapshot, invalid_sha1 +) + + +@given(content()) +def test_content_badge(client, content): + _test_badge_endpoints(client, CONTENT, content['sha1_git']) + + +@given(directory()) +def test_directory_badge(client, directory): + _test_badge_endpoints(client, DIRECTORY, directory) + + +@given(origin()) +def test_origin_badge(client, origin): + _test_badge_endpoints(client, ORIGIN, origin['url']) + + +@given(release()) +def test_release_badge(client, release): + _test_badge_endpoints(client, RELEASE, release) + + +@given(revision()) +def test_revision_badge(client, revision): + _test_badge_endpoints(client, REVISION, revision) + + +@given(snapshot()) +def test_snapshot_badge(client, snapshot): + _test_badge_endpoints(client, SNAPSHOT, snapshot) + + +@given(unknown_content(), unknown_directory(), new_origin(), + unknown_release(), unknown_revision(), unknown_snapshot(), + invalid_sha1()) +def test_badge_errors(client, unknown_content, unknown_directory, new_origin, + unknown_release, unknown_revision, unknown_snapshot, + invalid_sha1): + for object_type, object_id in ( + (CONTENT, unknown_content['sha1_git']), + (DIRECTORY, unknown_directory), + (ORIGIN, new_origin['url']), + (RELEASE, unknown_release), + (REVISION, unknown_revision), + (SNAPSHOT, unknown_snapshot) + ): + url_args = { + 'object_type': object_type, + 'object_id': object_id + } + url = reverse('swh-badge', url_args=url_args) + resp = client.get(url) + _check_generated_badge(resp, 404, **url_args) + + if object_type != ORIGIN: + object_pid = persistent_identifier(object_type, object_id) + url = reverse('swh-badge-pid', + url_args={'object_pid': object_pid}) + resp = client.get(url) + _check_generated_badge(resp, 404, **url_args) + + for object_type, object_id in ( + (CONTENT, invalid_sha1), + (DIRECTORY, invalid_sha1), + (RELEASE, invalid_sha1), + (REVISION, invalid_sha1), + (SNAPSHOT, invalid_sha1) + ): + url_args = { + 'object_type': object_type, + 'object_id': object_id + } + url = reverse('swh-badge', url_args=url_args) + resp = client.get(url) + _check_generated_badge(resp, 400, **url_args) + + object_pid = f'swh:1:{object_type[:3]}:{object_id}' + url = reverse('swh-badge-pid', + url_args={'object_pid': object_pid}) + resp = client.get(url) + _check_generated_badge(resp, 400, '', '') + + +@given(origin(), release()) +def test_badge_endpoints_have_cors_header(client, origin, release): + url = reverse('swh-badge', url_args={'object_type': ORIGIN, + 'object_id': origin['url']}) + resp = client.get(url, HTTP_ORIGIN='https://example.org') + assert resp.status_code == 200, resp.content + assert ACCESS_CONTROL_ALLOW_ORIGIN in resp + + release_pid = persistent_identifier(RELEASE, release) + url = reverse('swh-badge-pid', url_args={'object_pid': release_pid}) + resp = client.get(url, HTTP_ORIGIN='https://example.org') + assert resp.status_code == 200, resp.content + assert ACCESS_CONTROL_ALLOW_ORIGIN in resp + + +def _test_badge_endpoints(client, object_type, object_id): + url_args = {'object_type': object_type, + 'object_id': object_id} + url = reverse('swh-badge', url_args=url_args) + resp = client.get(url) + _check_generated_badge(resp, 200, **url_args) + if object_type != ORIGIN: + pid = persistent_identifier(object_type, object_id) + url = reverse('swh-badge-pid', url_args={'object_pid': pid}) + resp = client.get(url) + _check_generated_badge(resp, 200, **url_args) + + +def _check_generated_badge(response, status_code, object_type, object_id): + assert response.status_code == status_code, response.content + assert response['Content-Type'] == 'image/svg+xml' + + if not object_type: + object_type = 'object' + + if object_type == ORIGIN and status_code == 200: + link = reverse('browse-origin', url_args={'origin_url': object_id}) + text = 'repository' + elif status_code == 200: + text = persistent_identifier(object_type, object_id) + link = resolve_swh_persistent_id(text)['browse_url'] + if object_type == RELEASE: + release = service.lookup_release(object_id) + text = release['name'] + elif status_code == 400: + text = 'error' + link = f'invalid {object_type} id' + object_type = 'error' + elif status_code == 404: + text = 'error' + link = f'{object_type} not found' + object_type = 'error' + + assert_contains(response, '', status_code=status_code) + assert_contains(response, _get_logo_data(), status_code=status_code) + assert_contains(response, _badge_config[object_type]['color'], + status_code=status_code) + assert_contains(response, _badge_config[object_type]['title'], + status_code=status_code) + assert_contains(response, text, status_code=status_code) + assert_contains(response, link, status_code=status_code)