From bda48e2ba008f4d7dde3a15d86bfccde31d20233 Mon Sep 17 00:00:00 2001 From: Jeny Sadadia Date: Tue, 19 Mar 2024 15:39:22 +0530 Subject: [PATCH 1/8] Add support for Python 3.11 Upgrade Python version to 3.11 for API. Update docker recipe that is being used for Developer mode to use `Python 3.11` image. Update requirements version to enable support for Python 3.11. Below are the required changes: 1. `httptools` that is a requirement for `uvicorn`, failed to build for existing package version that was `0.13.4`. ``` gcc -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -fPIC -I/usr/local/include/python3.11 -I/tmp/pip-install-o_24u7rm/httptools_8955220ea3f34d0bae14ed2b27b468ae/vendor/http-parser -c httptools/parser/parser.c -o build/temp.linux-x86_64-cpython-311/httptools/parser/parser.o -O2 httptools/parser/parser.c:212:12: fatal error: longintrepr.h: No such file or directory 212 | #include "longintrepr.h" | ^~~~~~~~~~~~~~~ compilation terminated. error: command '/usr/bin/gcc' failed with exit code 1 [end of output] note: This error originates from a subprocess, and is likely not a problem with pip. ERROR: Failed building wheel for httptools ``` Fix it by using `uvicorn` version with Python 3.11 support i.e. `0.29.0`. 2. Since `uvicorn` is one of the requirements of `fastapi`, we also need to update its version to make it compatible with `uvicorn`. The fastapi version `0.99.1` has been selected to avoid dependency conflicts with existing `fastapi-users` version i.e. `10.4.0`. Otherwise the below build failure will take place: ``` ERROR: Cannot install fastapi-users, fastapi-users[beanie,oauth]==10.4.0 and fastapi[all]==0.110.0 because these package versions have conflicting dependencies. ``` 3. `fastapi==0.99.1` also removes the requirement of pinning `pyyaml` version. Dropped: 473bd7b ("docker/api: pin PyYAML version to 5.3.1") 4. Also, upgrade `motor` version to use it with python 3.11. 5. Upgrade `pylint` to the latest version i.e. `3.1.0`to resolve below incompatibility issue from `wrapt` package: ``` File "/usr/local/lib/python3.11/site-packages/wrapt/decorators.py", line 34, in from inspect import ismethod, isclass, formatargspec ImportError: cannot import name 'formatargspec' from 'inspect' (/usr/local/lib/python3.11/inspect.py) ``` Signed-off-by: Jeny Sadadia --- docker/api/Dockerfile | 5 +++-- docker/api/requirements-dev.txt | 2 +- docker/api/requirements.txt | 7 +++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docker/api/Dockerfile b/docker/api/Dockerfile index 9209bd8b..9b9b43d3 100644 --- a/docker/api/Dockerfile +++ b/docker/api/Dockerfile @@ -1,9 +1,10 @@ # SPDX-License-Identifier: LGPL-2.1-or-later # -# Copyright (C) 2021 Collabora Limited +# Copyright (C) 2021-2024 Collabora Limited # Author: Guillaume Tucker +# Author: Jeny Sadadia -FROM python:3.10 +FROM python:3.11 MAINTAINER "KernelCI TSC" RUN apt-get update && apt-get install --no-install-recommends -y git diff --git a/docker/api/requirements-dev.txt b/docker/api/requirements-dev.txt index e55054e9..89504fd8 100644 --- a/docker/api/requirements-dev.txt +++ b/docker/api/requirements-dev.txt @@ -1,3 +1,3 @@ -r requirements-tests.txt pycodestyle==2.8.0 -pylint==2.12.2 +pylint==3.1.0 diff --git a/docker/api/requirements.txt b/docker/api/requirements.txt index 52106284..5e5c6788 100644 --- a/docker/api/requirements.txt +++ b/docker/api/requirements.txt @@ -1,14 +1,13 @@ cloudevents==1.9.0 -fastapi[all]==0.68.1 +fastapi[all]==0.99.1 fastapi-pagination==0.9.3 fastapi-users[beanie, oauth]==10.4.0 fastapi-versioning==0.10.0 MarkupSafe==2.0.1 -motor==2.5.1 +motor==3.3.2 passlib==1.7.4 pydantic==1.10.5 pymongo-migrate==0.11.0 python-jose[cryptography]==3.3.0 -pyyaml==5.3.1 redis==5.0.1 -uvicorn[standard]==0.13.4 +uvicorn[standard]==0.29.0 From c34360c0be46283cb0eb0d7dc02ae3c4d280c863 Mon Sep 17 00:00:00 2001 From: Jeny Sadadia Date: Tue, 19 Mar 2024 16:22:00 +0530 Subject: [PATCH 2/8] pyproject.toml: support for Python 3.11 Upgrade python package version to make them compatible with Python 3.11. Match versions with requirements specified in `docker/api/requirements.txt` and `docker/api/requirements-dev.txt`. Signed-off-by: Jeny Sadadia --- pyproject.toml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3978c93d..62742351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,19 +13,18 @@ requires-python = ">=3.10" license = {text = "LGPL-2.1-or-later"} dependencies = [ "cloudevents == 1.9.0", - "fastapi[all] == 0.68.1", + "fastapi[all] == 0.99.1", "fastapi-pagination == 0.9.3", "fastapi-users[beanie, oauth] == 10.4.0", "fastapi-versioning == 0.10.0", "MarkupSafe == 2.0.1", - "motor == 2.5.1", + "motor == 3.3.2", "passlib == 1.7.4", "pydantic == 1.10.5", "pymongo-migrate == 0.11.0", "python-jose[cryptography] == 3.3.0", - "pyyaml == 5.3.1", "redis == 5.0.1", - "uvicorn[standard] == 0.13.4", + "uvicorn[standard] == 0.29.0", ] [project.optional-dependencies] @@ -42,7 +41,7 @@ tests = [ dev = [ "kernelci-api[tests]", "pycodestyle == 2.8.0", - "pylint == 2.12.2", + "pylint == 3.1.0", ] [project.urls] From 069e4719557984679c064d0a2f2c6706662056d2 Mon Sep 17 00:00:00 2001 From: Jeny Sadadia Date: Wed, 20 Mar 2024 18:46:44 +0530 Subject: [PATCH 3/8] .github/workflows/main.yml: add python 3.11 version As a part of adding support for Python 3.11 in API, add `3.11` version for `check` job. Since GH action for linter annotation is not tested with `python 3.11` yet, I am keeping its version `3.10` as of now. Signed-off-by: Jeny Sadadia --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 28c03a8f..f8077662 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: - python-version: ['3.10'] # Only supported one at the moment + python-version: ['3.10', '3.11'] steps: From 85c83f8007d578c3ace20e393d5b014bbb75448d Mon Sep 17 00:00:00 2001 From: Jeny Sadadia Date: Wed, 20 Mar 2024 19:09:55 +0530 Subject: [PATCH 4/8] .github/workflows/test.yml: add python 3.11 version Add python 3.11 to supported version for running Github actions for tests. Signed-off-by: Jeny Sadadia --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 24054bd4..e1bf4c0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: - python-version: ['3.10'] # Only supported one at the moment + python-version: ['3.10', '3.11'] steps: From dcd4f6a8178d5f7c7329b47039305f7e3b740899 Mon Sep 17 00:00:00 2001 From: Jeny Sadadia Date: Thu, 21 Mar 2024 11:43:15 +0530 Subject: [PATCH 5/8] api.main: fix validation error for `None` response FastAPI doesn't validate `None` when handler has specified a response model. Fix the below validation error for GET node by ID and GET group by ID requests: ``` {"detail":"1 validation error for UserGroup\nresponse\n none is not an allowed value (type=type_error.none.not_allowed)"} ``` The change has been effective from `fastapi 0.80.0` version. Reference: https://github.com/tiangolo/fastapi/pull/2725. Signed-off-by: Jeny Sadadia --- api/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/main.py b/api/main.py index 68998414..9e16a586 100644 --- a/api/main.py +++ b/api/main.py @@ -10,7 +10,7 @@ import os import re -from typing import List +from typing import List, Union from fastapi import ( Depends, FastAPI, @@ -387,7 +387,7 @@ async def get_user_groups(request: Request): return paginated_resp -@app.get('/group/{group_id}', response_model=UserGroup, +@app.get('/group/{group_id}', response_model=Union[UserGroup, None], response_model_by_alias=False) async def get_group(group_id: str): """Get user group information from the provided group id""" @@ -448,7 +448,7 @@ async def translate_null_query_params(query_params: dict): return translated -@app.get('/node/{node_id}', response_model=Node, +@app.get('/node/{node_id}', response_model=Union[Node, None], response_model_by_alias=False) async def get_node(node_id: str): """Get node information from the provided node id""" From 0f169d301fabfa66869b537ac1615333b41b5903 Mon Sep 17 00:00:00 2001 From: Jeny Sadadia Date: Fri, 22 Mar 2024 15:26:10 +0530 Subject: [PATCH 6/8] tests/unit_tests: fix tests broken by `fastapi` upgrade The `fastapi` version upgrade affected unit tests as `TestClient` has been upgraded to use `httpx`. Reference to the release note: https://fastapi.tiangolo.com/release-notes/#0870 This introduced the below failure in the API unit tests: ``` > raise RuntimeError('Event loop is closed') E RuntimeError: Event loop is closed /usr/local/lib/python3.11/asyncio/base_events.py:520: RuntimeError ``` Based on the observation, it seems like somehow `TestClient` is closing event loop after executing the first test. It is backed by the GH issue: https://github.com/encode/starlette/discussions/2069 Fix the issue by using the same test client for all the synchronous tests. Fixture `event_loop` is not required anymore after changing `TestClient` scope to `session`. Hence, drop it. Signed-off-by: Jeny Sadadia --- tests/unit_tests/conftest.py | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index f029382a..79632e43 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -11,7 +11,6 @@ """pytest fixtures for KernelCI API""" from unittest.mock import AsyncMock -import asyncio import fakeredis.aioredis from fastapi.testclient import TestClient from fastapi import Request, HTTPException, status @@ -96,12 +95,12 @@ def mock_get_current_admin_user(request: Request): app.dependency_overrides[get_current_superuser] = mock_get_current_admin_user -@pytest.fixture +@pytest.fixture(scope='session') def test_client(): """Fixture to get FastAPI Test client instance""" # Mock dependency callables for getting current user with TestClient(app=versioned_app, base_url=BASE_URL) as client: - return client + yield client @pytest.fixture @@ -113,20 +112,6 @@ async def test_async_client(): await versioned_app.router.shutdown() -@pytest.fixture -def event_loop(): - """Create an instance of the default event loop for each test case. - This is a workaround to prevent the default event loop to be closed by - async pubsub tests. It was causing other tests unable to run. - The issue has already been reported here: - https://github.com/pytest-dev/pytest-asyncio/issues/371 - """ - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - yield loop - loop.close() - - @pytest.fixture def mock_db_create(mocker): """Mocks async call to Database class method used to create object""" From 1fce878fbc33de1c8b8f3c74d4265803c7fbbae5 Mon Sep 17 00:00:00 2001 From: Jeny Sadadia Date: Fri, 22 Mar 2024 15:40:42 +0530 Subject: [PATCH 7/8] tests/unit_tests: fix comment placement A minor fixup related to comment placement. Fixes: 18b106c ("tests/unit_tests: update user related tests") Signed-off-by: Jeny Sadadia --- tests/unit_tests/conftest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 79632e43..4447b1b9 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -90,7 +90,7 @@ def mock_get_current_admin_user(request: Request): is_verified=True ) - +# Mock dependency callables for getting current user app.dependency_overrides[get_current_user] = mock_get_current_user app.dependency_overrides[get_current_superuser] = mock_get_current_admin_user @@ -98,7 +98,6 @@ def mock_get_current_admin_user(request: Request): @pytest.fixture(scope='session') def test_client(): """Fixture to get FastAPI Test client instance""" - # Mock dependency callables for getting current user with TestClient(app=versioned_app, base_url=BASE_URL) as client: yield client From 4fb627b8f37ea4e99a5aac3bb1b0277bbea89922 Mon Sep 17 00:00:00 2001 From: Jeny Sadadia Date: Tue, 26 Mar 2024 16:21:42 +0530 Subject: [PATCH 8/8] tests/e2e_tests: drop synchronous test client Due to `fastapi` version change, the newer `TestClient` from `fastapi` is causing issues by creating its own different event loop (Reference: https://www.starlette.io/testclient/). That conflicts with the event loop of database client created in the app startup handler. Fix the below error by using async test client for all the tests: ``` @pytest.fixture(scope='session') def test_client(): """Fixture to get FastAPI Test client instance""" with TestClient(app=versioned_app, base_url=BASE_URL) as client: tests/e2e_tests/conftest.py:36: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ api/main.py:87: in initialize_beanie await db.initialize_beanie() api/db.py:51: in initialize_beanie await init_beanie( .local/lib/python3.11/site-packages/beanie/odm/utils/init.py:750: in init_beanie await Initializer( .local/lib/python3.11/site-packages/beanie/odm/utils/init.py:128: in __await__ yield from self.init_class(model).__await__() .local/lib/python3.11/site-packages/beanie/odm/utils/init.py:713: in init_class await self.init_document(cls) _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = cls = > build_info = await self.database.command({"buildInfo": 1}) E RuntimeError: Task cb=[TaskGroup._spawn..task_done() at /home/kernelci/.local/lib/python3.11/site-packages/anyio/_backends/_asyncio.py:699]> got Future ._call_check_cancel() at /usr/local/lib/python3.11/asyncio/futures.py:387]> attached to a different loop .local/lib/python3.11/site-packages/beanie/odm/utils/init.py:545: RuntimeError ``` Signed-off-by: Jeny Sadadia --- tests/e2e_tests/conftest.py | 8 -------- tests/e2e_tests/test_count_handler.py | 10 ++++++---- tests/e2e_tests/test_root_handler.py | 7 +++++-- tests/e2e_tests/test_subscribe_handler.py | 15 +++++++++------ tests/e2e_tests/test_unsubscribe_handler.py | 10 ++++++---- tests/e2e_tests/test_user_creation.py | 10 ++++++---- 6 files changed, 32 insertions(+), 28 deletions(-) diff --git a/tests/e2e_tests/conftest.py b/tests/e2e_tests/conftest.py index 10d6ab09..b86a818a 100644 --- a/tests/e2e_tests/conftest.py +++ b/tests/e2e_tests/conftest.py @@ -10,8 +10,6 @@ from httpx import AsyncClient from motor.motor_asyncio import AsyncIOMotorClient -from fastapi.testclient import TestClient - from api.main import versioned_app from kernelci.api.models import Node, Regression @@ -30,12 +28,6 @@ 'offset', } -@pytest.fixture(scope='session') -def test_client(): - """Fixture to get FastAPI Test client instance""" - with TestClient(app=versioned_app, base_url=BASE_URL) as client: - yield client - @pytest.fixture(scope='session') async def test_async_client(): diff --git a/tests/e2e_tests/test_count_handler.py b/tests/e2e_tests/test_count_handler.py index b65c6bcf..8508a47f 100644 --- a/tests/e2e_tests/test_count_handler.py +++ b/tests/e2e_tests/test_count_handler.py @@ -10,33 +10,35 @@ import pytest +@pytest.mark.asyncio @pytest.mark.dependency( depends=[ 'tests/e2e_tests/test_pipeline.py::test_node_pipeline'], scope='session') -def test_count_nodes(test_client): +async def test_count_nodes(test_async_client): """ Test Case : Test KernelCI API GET /count endpoint Expected Result : HTTP Response Code 200 OK Total number of nodes available """ - response = test_client.get("count") + response = await test_async_client.get("count") assert response.status_code == 200 assert response.json() >= 0 +@pytest.mark.asyncio @pytest.mark.dependency( depends=[ 'tests/e2e_tests/test_pipeline.py::test_node_pipeline'], scope='session') -def test_count_nodes_matching_attributes(test_client): +async def test_count_nodes_matching_attributes(test_async_client): """ Test Case : Test KernelCI API GET /count endpoint with attributes Expected Result : HTTP Response Code 200 OK Number of nodes matching attributes """ - response = test_client.get("count?name=checkout") + response = await test_async_client.get("count?name=checkout") assert response.status_code == 200 assert response.json() >= 0 diff --git a/tests/e2e_tests/test_root_handler.py b/tests/e2e_tests/test_root_handler.py index 75d842e2..56a3cbc2 100644 --- a/tests/e2e_tests/test_root_handler.py +++ b/tests/e2e_tests/test_root_handler.py @@ -5,9 +5,12 @@ """End-to-end test functions for KernelCI API root handler""" +import pytest -def test_root_endpoint(test_client): + +@pytest.mark.asyncio +async def test_root_endpoint(test_async_client): """Test root handler""" - response = test_client.get("/latest") + response = await test_async_client.get("/") assert response.status_code == 200 assert response.json() == {"message": "KernelCI API"} diff --git a/tests/e2e_tests/test_subscribe_handler.py b/tests/e2e_tests/test_subscribe_handler.py index f9a8ee72..11ddef79 100644 --- a/tests/e2e_tests/test_subscribe_handler.py +++ b/tests/e2e_tests/test_subscribe_handler.py @@ -9,18 +9,19 @@ import pytest +@pytest.mark.asyncio @pytest.mark.dependency( depends=['tests/e2e_tests/test_user_creation.py::test_create_regular_user'], scope='session') @pytest.mark.order(3) -def test_subscribe_node_channel(test_client): +async def test_subscribe_node_channel(test_async_client): """ Test Case : Test KernelCI API '/subscribe' endpoint with 'node' channel Expected Result : HTTP Response Code 200 OK JSON with subscription 'id' and 'channel' keys """ - response = test_client.post( + response = await test_async_client.post( "subscribe/node", headers={ "Authorization": f"Bearer {pytest.BEARER_TOKEN}" # pylint: disable=no-member @@ -32,18 +33,19 @@ def test_subscribe_node_channel(test_client): assert response.json().get('channel') == 'node' +@pytest.mark.asyncio @pytest.mark.dependency( depends=['tests/e2e_tests/test_user_creation.py::test_create_regular_user'], scope='session') @pytest.mark.order(3) -def test_subscribe_test_channel(test_client): +async def test_subscribe_test_channel(test_async_client): """ Test Case : Test KernelCI API '/subscribe' endpoint with 'test_channel' Expected Result : HTTP Response Code 200 OK JSON with subscription 'id' and 'channel' keys """ - response = test_client.post( + response = await test_async_client.post( "subscribe/test_channel", headers={ "Authorization": f"Bearer {pytest.BEARER_TOKEN}" # pylint: disable=no-member @@ -55,11 +57,12 @@ def test_subscribe_test_channel(test_client): assert response.json().get('channel') == 'test_channel' +@pytest.mark.asyncio @pytest.mark.dependency( depends=['tests/e2e_tests/test_user_creation.py::test_create_regular_user'], scope='session') @pytest.mark.order(3) -def test_subscribe_user_group_channel(test_client): +async def test_subscribe_user_group_channel(test_async_client): """ Test Case : Test KernelCI API '/subscribe' endpoint with 'user_group' channel @@ -67,7 +70,7 @@ def test_subscribe_user_group_channel(test_client): HTTP Response Code 200 OK JSON with subscription 'id' and 'channel' keys """ - response = test_client.post( + response = await test_async_client.post( "subscribe/user_group", headers={ "Authorization": f"Bearer {pytest.BEARER_TOKEN}" # pylint: disable=no-member diff --git a/tests/e2e_tests/test_unsubscribe_handler.py b/tests/e2e_tests/test_unsubscribe_handler.py index a787da4d..62932b7f 100644 --- a/tests/e2e_tests/test_unsubscribe_handler.py +++ b/tests/e2e_tests/test_unsubscribe_handler.py @@ -9,18 +9,19 @@ import pytest +@pytest.mark.asyncio @pytest.mark.dependency( depends=[ 'tests/e2e_tests/test_subscribe_handler.py::test_subscribe_node_channel'], scope='session') @pytest.mark.order("last") -def test_unsubscribe_node_channel(test_client): +async def test_unsubscribe_node_channel(test_async_client): """ Test Case : Test KernelCI API '/unsubscribe' endpoint with 'node' channel Expected Result : HTTP Response Code 200 OK """ - response = test_client.post( + response = await test_async_client.post( f"unsubscribe/{pytest.node_channel_subscription_id}", # pylint: disable=no-member headers={ "Authorization": f"Bearer {pytest.BEARER_TOKEN}" # pylint: disable=no-member @@ -29,18 +30,19 @@ def test_unsubscribe_node_channel(test_client): assert response.status_code == 200 +@pytest.mark.asyncio @pytest.mark.dependency( depends=[ 'tests/e2e_tests/test_subscribe_handler.py::test_subscribe_test_channel'], scope='session') @pytest.mark.order("last") -def test_unsubscribe_test_channel(test_client): +async def test_unsubscribe_test_channel(test_async_client): """ Test Case : Test KernelCI API '/unsubscribe' endpoint with 'test_channel' Expected Result : HTTP Response Code 200 OK """ - response = test_client.post( + response = await test_async_client.post( f"unsubscribe/{pytest.test_channel_subscription_id}", # pylint: disable=no-member headers={ "Authorization": f"Bearer {pytest.BEARER_TOKEN}" # pylint: disable=no-member diff --git a/tests/e2e_tests/test_user_creation.py b/tests/e2e_tests/test_user_creation.py index 69844b73..e8fd222e 100644 --- a/tests/e2e_tests/test_user_creation.py +++ b/tests/e2e_tests/test_user_creation.py @@ -118,8 +118,9 @@ async def test_create_regular_user(test_async_client): pytest.BEARER_TOKEN = response.json()['access_token'] +@pytest.mark.asyncio @pytest.mark.dependency(depends=["test_create_regular_user"]) -def test_whoami(test_client): +async def test_whoami(test_async_client): """ Test Case : Test KernelCI API /whoami endpoint Expected Result : @@ -127,7 +128,7 @@ def test_whoami(test_client): JSON with 'id', 'email', username', 'groups', 'is_superuser' 'is_verified' and 'is_active' keys """ - response = test_client.get( + response = await test_async_client.get( "whoami", headers={ "Accept": "application/json", @@ -140,8 +141,9 @@ def test_whoami(test_client): assert response.json()['username'] == 'test_user' +@pytest.mark.asyncio @pytest.mark.dependency(depends=["test_create_regular_user"]) -def test_create_user_negative(test_client): +async def test_create_user_negative(test_async_client): """ Test Case : Test KernelCI API /user/register endpoint when requested with regular user's bearer token. @@ -149,7 +151,7 @@ def test_create_user_negative(test_client): HTTP Response Code 403 Forbidden JSON with 'detail' key denoting 'Forbidden' error """ - response = test_client.post( + response = await test_async_client.post( "user/register", headers={ "Accept": "application/json",