]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19968 Web API v2 Documentation
authorViktor Vorona <viktor.vorona@sonarsource.com>
Mon, 24 Jul 2023 11:41:28 +0000 (13:41 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 31 Jul 2023 20:03:31 +0000 (20:03 +0000)
27 files changed:
server/sonar-web/design-system/src/components/Accordion.tsx
server/sonar-web/design-system/src/components/BorderlessAccordion.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/Accordion-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/BorderlessAccordion-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/package.json
server/sonar-web/src/main/js/api/mocks/WebApiServiceMock.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/mocks/data/web-api.ts [new file with mode: 0644]
server/sonar-web/src/main/js/api/web-api.ts
server/sonar-web/src/main/js/app/components/GlobalContainer.tsx
server/sonar-web/src/main/js/app/utils/startReactApp.tsx
server/sonar-web/src/main/js/apps/overview/branches/QualityGatePanelSection.tsx
server/sonar-web/src/main/js/apps/web-api-v2/WebApiApp.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/__tests__/WebApiApp-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/__tests__/utils-test.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/components/ApiInformation.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/components/ApiParameters.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/components/ApiResponseSchema.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/components/ApiResponseTitle.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/components/ApiResponses.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/components/ApiSidebar.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/queries.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/routes.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/types.ts [new file with mode: 0644]
server/sonar-web/src/main/js/apps/web-api-v2/utils.ts [new file with mode: 0644]
server/sonar-web/yarn.lock
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 84fcdf41571bdc2f7697402104b54d9b3f56f52a..b6a9770898881c2d15c87614daa70f5fa275ecff 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
 import classNames from 'classnames';
 import { uniqueId } from 'lodash';
-import * as React from 'react';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor, themeContrast } from '../helpers';
+import { ThemedProps } from '../types';
 import { BareButton } from './buttons';
 import { OpenCloseIndicator } from './icons/OpenCloseIndicator';
 
@@ -42,7 +47,7 @@ export function Accordion(props: AccordionProps) {
   }, [onClick, data]);
 
   return (
-    <div
+    <Container
       className={classNames('sw-cursor-pointer', className, {
         open,
       })}
@@ -52,7 +57,7 @@ export function Accordion(props: AccordionProps) {
         aria-controls={`${id}-panel`}
         aria-expanded={open}
         aria-label={ariaLabel}
-        className="sw-flex sw-items-center sw-justify-between sw-px-2 sw-py-2 sw-box-border sw-w-full"
+        className="sw-flex sw-items-center sw-justify-between sw-p-4 sw-box-border sw-w-full"
         id={`${id}-header`}
         onClick={handleClick}
       >
@@ -60,8 +65,53 @@ export function Accordion(props: AccordionProps) {
         <OpenCloseIndicator aria-hidden className="sw-ml-2" open={open} />
       </BareButton>
       <div aria-labelledby={`${id}-header`} id={`${id}-panel`} role="region">
-        {open && <div>{props.children}</div>}
+        {open && <div className="sw-p-4">{props.children}</div>}
       </div>
-    </div>
+    </Container>
   );
 }
+
+const accordionStyle = (props: ThemedProps) => css`
+  box-sizing: border-box;
+  text-decoration: none;
+  outline: none;
+  border: ${themeBorder('default', 'buttonSecondaryBorder')(props)};
+  color: ${themeContrast('buttonSecondary')(props)};
+  background-color: ${themeColor('buttonSecondary')(props)};
+  transition: background-color 0.2s ease, outline 0.2s ease;
+
+  & > button {
+    ${tw`sw-body-sm-highlight`}
+  }
+  ${tw`sw-rounded-2`}
+  ${tw`sw-overflow-hidden`}
+  ${tw`sw-cursor-pointer`}
+
+  & > button:hover, & > button:active {
+    color: ${themeContrast('buttonSecondary')(props)};
+    background-color: ${themeColor('buttonSecondaryHover')(props)};
+  }
+
+  & > button:focus,
+  & > button:active {
+    color: ${themeContrast('buttonSecondary')(props)};
+    outline: ${themeBorder('focus', 'var(--focus)')(props)};
+  }
+
+  & > button:disabled,
+  & > button:disabled:hover {
+    color: ${themeContrast('buttonDisabled')(props)};
+    background-color: ${themeColor('buttonDisabled')(props)};
+    border: ${themeBorder('default', 'buttonDisabledBorder')(props)};
+
+    ${tw`sw-cursor-not-allowed`}
+  }
+
+  & > svg {
+    ${tw`sw-mr-1`}
+  }
+`;
+
+const Container = styled.div`
+  ${accordionStyle}
+`;
diff --git a/server/sonar-web/design-system/src/components/BorderlessAccordion.tsx b/server/sonar-web/design-system/src/components/BorderlessAccordion.tsx
new file mode 100644 (file)
index 0000000..5765708
--- /dev/null
@@ -0,0 +1,67 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import classNames from 'classnames';
+import { uniqueId } from 'lodash';
+import * as React from 'react';
+import { BareButton } from './buttons';
+import { OpenCloseIndicator } from './icons/OpenCloseIndicator';
+
+interface AccordionProps {
+  ariaLabel?: string;
+  children: React.ReactNode;
+  className?: string;
+  data?: string;
+  header: React.ReactNode;
+  onClick: (data?: string) => void;
+  open: boolean;
+}
+
+export function BorderlessAccordion(props: AccordionProps) {
+  const { ariaLabel, className, open, header, data, onClick } = props;
+
+  const id = React.useMemo(() => uniqueId('accordion-'), []);
+  const handleClick = React.useCallback(() => {
+    onClick(data);
+  }, [onClick, data]);
+
+  return (
+    <div
+      className={classNames('sw-cursor-pointer', className, {
+        open,
+      })}
+      role="listitem"
+    >
+      <BareButton
+        aria-controls={`${id}-panel`}
+        aria-expanded={open}
+        aria-label={ariaLabel}
+        className="sw-flex sw-items-center sw-justify-between sw-px-2 sw-py-2 sw-box-border sw-w-full"
+        id={`${id}-header`}
+        onClick={handleClick}
+      >
+        {header}
+        <OpenCloseIndicator aria-hidden className="sw-ml-2" open={open} />
+      </BareButton>
+      <div aria-labelledby={`${id}-header`} id={`${id}-panel`} role="region">
+        {open && <div>{props.children}</div>}
+      </div>
+    </div>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/Accordion-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Accordion-test.tsx
deleted file mode 100644 (file)
index 323175c..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import * as React from 'react';
-import { render } from '../../helpers/testUtils';
-import { Accordion } from '../Accordion';
-
-it('should behave correctly', async () => {
-  const user = userEvent.setup();
-  const children = 'hello';
-  renderAccordion(children);
-  expect(screen.queryByText(children)).not.toBeInTheDocument();
-  await user.click(screen.getByRole('button', { expanded: false }));
-  expect(screen.getByText(children)).toBeInTheDocument();
-});
-
-function renderAccordion(children: React.ReactNode) {
-  function AccordionTest() {
-    const [open, setOpen] = React.useState(false);
-
-    return (
-      <Accordion
-        header="test"
-        onClick={() => {
-          setOpen(!open);
-        }}
-        open={open}
-      >
-        <div>{children}</div>
-      </Accordion>
-    );
-  }
-
-  return render(<AccordionTest />);
-}
diff --git a/server/sonar-web/design-system/src/components/__tests__/BorderlessAccordion-test.tsx b/server/sonar-web/design-system/src/components/__tests__/BorderlessAccordion-test.tsx
new file mode 100644 (file)
index 0000000..7598cf3
--- /dev/null
@@ -0,0 +1,53 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+import { render } from '../../helpers/testUtils';
+import { BorderlessAccordion } from '../BorderlessAccordion';
+
+it('should behave correctly', async () => {
+  const user = userEvent.setup();
+  const children = 'hello';
+  renderAccordion(children);
+  expect(screen.queryByText(children)).not.toBeInTheDocument();
+  await user.click(screen.getByRole('button', { expanded: false }));
+  expect(screen.getByText(children)).toBeInTheDocument();
+});
+
+function renderAccordion(children: React.ReactNode) {
+  function AccordionTest() {
+    const [open, setOpen] = React.useState(false);
+
+    return (
+      <BorderlessAccordion
+        header="test"
+        onClick={() => {
+          setOpen(!open);
+        }}
+        open={open}
+      >
+        <div>{children}</div>
+      </BorderlessAccordion>
+    );
+  }
+
+  return render(<AccordionTest />);
+}
index 0e4783df60773a472b8dfeed2f67a183d8579731..5536c8be6cd42ac4426638f2b40647ec723e880d 100644 (file)
@@ -23,6 +23,7 @@ export * from './Avatar';
 export { Badge } from './Badge';
 export * from './Banner';
 export { BarChart } from './BarChart';
+export * from './BorderlessAccordion';
 export { Breadcrumbs } from './Breadcrumbs';
 export * from './BubbleChart';
 export * from './Card';
index dfdd3cf70929883696a5b7ed641b3e0e0a4e354c..c7808145a3014c06b5867d5d5dda529649b5cf09 100644 (file)
     "jest-environment-jsdom": "29.5.0",
     "jest-junit": "16.0.0",
     "jsdom": "21.1.1",
+    "openapi-types": "12.1.3",
     "path-browserify": "1.0.1",
     "postcss": "8.4.24",
     "postcss-calc": "9.0.1",
diff --git a/server/sonar-web/src/main/js/api/mocks/WebApiServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/WebApiServiceMock.ts
new file mode 100644 (file)
index 0000000..c6f5fc9
--- /dev/null
@@ -0,0 +1,43 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { cloneDeep } from 'lodash';
+import { OpenAPIV3 } from 'openapi-types';
+import { fetchOpenAPI } from '../web-api';
+import { openApiTestData } from './data/web-api';
+
+jest.mock('../web-api');
+
+export default class WebApiServiceMock {
+  openApiDocument: OpenAPIV3.Document;
+
+  constructor() {
+    this.openApiDocument = cloneDeep(openApiTestData);
+
+    jest.mocked(fetchOpenAPI).mockImplementation(this.handleFetchOpenAPI);
+  }
+
+  handleFetchOpenAPI: typeof fetchOpenAPI = () => {
+    return Promise.resolve(this.openApiDocument);
+  };
+
+  reset = () => {
+    this.openApiDocument = cloneDeep(openApiTestData);
+  };
+}
diff --git a/server/sonar-web/src/main/js/api/mocks/data/web-api.ts b/server/sonar-web/src/main/js/api/mocks/data/web-api.ts
new file mode 100644 (file)
index 0000000..1e5b475
--- /dev/null
@@ -0,0 +1,722 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { OpenAPIV3 } from 'openapi-types';
+
+export const openApiTestData: OpenAPIV3.Document = {
+  openapi: '3.0.2',
+  info: {
+    title: 'Swagger Petstore - OpenAPI 3.0',
+    description:
+      "This is a sample Pet Store Server based on the OpenAPI 3.0 specification.  You can find out more about\nSwagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)",
+    termsOfService: 'http://swagger.io/terms/',
+    contact: { email: 'apiteam@swagger.io' },
+    license: { name: 'Apache 2.0', url: 'http://www.apache.org/licenses/LICENSE-2.0.html' },
+    version: '1.0.17',
+  },
+  externalDocs: { description: 'Find out more about Swagger', url: 'http://swagger.io' },
+  servers: [{ url: '/api/v3' }],
+  tags: [
+    {
+      name: 'pet',
+      description: 'Everything about your Pets',
+      externalDocs: { description: 'Find out more', url: 'http://swagger.io' },
+    },
+    {
+      name: 'store',
+      description: 'Access to Petstore orders',
+      externalDocs: { description: 'Find out more about our store', url: 'http://swagger.io' },
+    },
+    { name: 'user', description: 'Operations about user' },
+  ],
+  paths: {
+    '/pet': {
+      put: {
+        tags: ['pet'],
+        summary: 'Update an existing pet',
+        description: 'Update an existing pet by Id',
+        operationId: 'updatePet',
+        requestBody: {
+          description: 'Update an existent pet in the store',
+          content: {
+            'application/json': { schema: { $ref: '#/components/schemas/Pet' } },
+            'application/xml': { schema: { $ref: '#/components/schemas/Pet' } },
+            'application/x-www-form-urlencoded': {
+              schema: { $ref: '#/components/schemas/Pet' },
+            },
+          },
+          required: true,
+        },
+        responses: {
+          '200': {
+            description: 'Successful operation',
+            content: {
+              'application/xml': { schema: { $ref: '#/components/schemas/Pet' } },
+              'application/json': { schema: { $ref: '#/components/schemas/Pet' } },
+            },
+          },
+          '400': { description: 'Invalid ID supplied' },
+          '404': { description: 'Pet not found' },
+          '405': { description: 'Validation exception' },
+        },
+        security: [{ petstore_auth: ['write:pets', 'read:pets'] }],
+      },
+      post: {
+        tags: ['pet'],
+        summary: 'Add a new pet to the store',
+        description: 'Add a new pet to the store',
+        operationId: 'addPet',
+        requestBody: {
+          description: 'Create a new pet in the store',
+          content: {
+            'application/json': { schema: { $ref: '#/components/schemas/Pet' } },
+            'application/xml': { schema: { $ref: '#/components/schemas/Pet' } },
+            'application/x-www-form-urlencoded': {
+              schema: { $ref: '#/components/schemas/Pet' },
+            },
+          },
+          required: true,
+        },
+        responses: {
+          '200': {
+            description: 'Successful operation',
+            content: {
+              'application/xml': { schema: { $ref: '#/components/schemas/Pet' } },
+              'application/json': { schema: { $ref: '#/components/schemas/Pet' } },
+            },
+          },
+          '405': { description: 'Invalid input' },
+        },
+        security: [{ petstore_auth: ['write:pets', 'read:pets'] }],
+      },
+    },
+    '/pet/findByStatus': {
+      get: {
+        tags: ['pet'],
+        summary: 'Finds Pets by status',
+        description: 'Multiple status values can be provided with comma separated strings',
+        operationId: 'findPetsByStatus',
+        parameters: [
+          {
+            name: 'status',
+            in: 'query',
+            description: 'Status values that need to be considered for filter',
+            required: false,
+            explode: true,
+            schema: {
+              type: 'string',
+              default: 'available',
+              enum: ['available', 'pending', 'sold'],
+            },
+          },
+        ],
+        responses: {
+          '200': {
+            description: 'successful operation',
+            content: {
+              'application/xml': {
+                schema: { type: 'array', items: { $ref: '#/components/schemas/Pet' } },
+              },
+              'application/json': {
+                schema: { type: 'array', items: { $ref: '#/components/schemas/Pet' } },
+              },
+            },
+          },
+          '400': { description: 'Invalid status value' },
+        },
+        security: [{ petstore_auth: ['write:pets', 'read:pets'] }],
+      },
+    },
+    '/pet/findByTags': {
+      get: {
+        tags: ['pet'],
+        summary: 'Finds Pets by tags',
+        description:
+          'Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.',
+        operationId: 'findPetsByTags',
+        parameters: [
+          {
+            name: 'tags',
+            in: 'query',
+            description: 'Tags to filter by',
+            required: false,
+            explode: true,
+            schema: { type: 'array', items: { type: 'string' } },
+          },
+        ],
+        responses: {
+          '200': {
+            description: 'successful operation',
+            content: {
+              'application/xml': {
+                schema: { type: 'array', items: { $ref: '#/components/schemas/Pet' } },
+              },
+              'application/json': {
+                schema: { type: 'array', items: { $ref: '#/components/schemas/Pet' } },
+              },
+            },
+          },
+          '400': { description: 'Invalid tag value' },
+        },
+        security: [{ petstore_auth: ['write:pets', 'read:pets'] }],
+      },
+    },
+    '/pet/{petId}': {
+      get: {
+        tags: ['pet'],
+        summary: 'Find pet by ID',
+        description: 'Returns a single pet',
+        operationId: 'getPetById',
+        parameters: [
+          {
+            name: 'petId',
+            in: 'path',
+            description: 'ID of pet to return',
+            required: true,
+            schema: { type: 'integer', format: 'int64' },
+          },
+        ],
+        responses: {
+          '200': {
+            description: 'successful operation',
+            content: {
+              'application/xml': { schema: { $ref: '#/components/schemas/Pet' } },
+              'application/json': { schema: { $ref: '#/components/schemas/Pet' } },
+            },
+          },
+          '400': { description: 'Invalid ID supplied' },
+          '404': { description: 'Pet not found' },
+        },
+        security: [{ api_key: [] }, { petstore_auth: ['write:pets', 'read:pets'] }],
+      },
+      post: {
+        tags: ['pet'],
+        summary: 'Updates a pet in the store with form data',
+        description: '',
+        operationId: 'updatePetWithForm',
+        parameters: [
+          {
+            name: 'petId',
+            in: 'path',
+            description: 'ID of pet that needs to be updated',
+            required: true,
+            schema: { type: 'integer', format: 'int64' },
+          },
+          {
+            name: 'name',
+            in: 'query',
+            description: 'Name of pet that needs to be updated',
+            deprecated: true,
+            schema: { type: 'string' },
+          },
+          {
+            name: 'status',
+            in: 'query',
+            description: 'Status of pet that needs to be updated',
+            example: 3,
+            schema: { type: 'integer', format: 'int32', maximum: 5, minimum: -1 },
+          },
+        ],
+        responses: { '405': { description: 'Invalid input' } },
+        security: [{ petstore_auth: ['write:pets', 'read:pets'] }],
+      },
+      delete: {
+        tags: ['pet'],
+        summary: 'Deletes a pet',
+        description: '',
+        operationId: 'deletePet',
+        parameters: [
+          {
+            name: 'api_key',
+            in: 'header',
+            description: '',
+            required: false,
+            schema: { type: 'string' },
+          },
+          {
+            name: 'petId',
+            in: 'path',
+            description: 'Pet id to delete',
+            required: true,
+            schema: { type: 'integer', format: 'int64' },
+          },
+        ],
+        responses: { '400': { description: 'Invalid pet value' } },
+        security: [{ petstore_auth: ['write:pets', 'read:pets'] }],
+      },
+    },
+    '/pet/{petId}/uploadImage': {
+      post: {
+        tags: ['pet'],
+        summary: 'uploads an image',
+        description: '',
+        operationId: 'uploadFile',
+        parameters: [
+          {
+            name: 'petId',
+            in: 'path',
+            description: 'ID of pet to update',
+            required: true,
+            schema: { type: 'integer', format: 'int64' },
+          },
+          {
+            name: 'additionalMetadata',
+            in: 'query',
+            description: 'Additional Metadata',
+            required: false,
+            schema: { type: 'string' },
+          },
+        ],
+        requestBody: {
+          content: {
+            'application/octet-stream': { schema: { type: 'string', format: 'binary' } },
+          },
+        },
+        responses: {
+          '200': {
+            description: 'successful operation',
+            content: {
+              'application/json': { schema: { $ref: '#/components/schemas/ApiResponse' } },
+            },
+          },
+        },
+        security: [{ petstore_auth: ['write:pets', 'read:pets'] }],
+      },
+    },
+    '/store/inventory': {
+      get: {
+        tags: ['store'],
+        summary: 'Returns pet inventories by status',
+        description: 'Returns a map of status codes to quantities',
+        operationId: 'getInventory',
+        responses: {
+          '200': {
+            description: 'successful operation',
+            content: {
+              'application/json': {
+                schema: {
+                  type: 'object',
+                  additionalProperties: { type: 'integer', format: 'int32' },
+                },
+              },
+            },
+          },
+        },
+        security: [{ api_key: [] }],
+      },
+    },
+    '/store/order': {
+      post: {
+        tags: ['store'],
+        summary: 'Place an order for a pet',
+        description: 'Place a new order in the store',
+        operationId: 'placeOrder',
+        requestBody: {
+          content: {
+            'application/json': { schema: { $ref: '#/components/schemas/Order' } },
+            'application/xml': { schema: { $ref: '#/components/schemas/Order' } },
+            'application/x-www-form-urlencoded': {
+              schema: { $ref: '#/components/schemas/Order' },
+            },
+          },
+        },
+        responses: {
+          '200': {
+            description: 'successful operation',
+            content: {
+              'application/json': { schema: { $ref: '#/components/schemas/Order' } },
+            },
+          },
+          '405': { description: 'Invalid input' },
+        },
+      },
+    },
+    '/store/order/{orderId}': {
+      get: {
+        tags: ['store'],
+        summary: 'Find purchase order by ID',
+        description:
+          'For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.',
+        operationId: 'getOrderById',
+        parameters: [
+          {
+            name: 'orderId',
+            in: 'path',
+            description: 'ID of order that needs to be fetched',
+            required: true,
+            schema: { type: 'integer', format: 'int64' },
+          },
+        ],
+        responses: {
+          '200': {
+            description: 'successful operation',
+            content: {
+              'application/xml': { schema: { $ref: '#/components/schemas/Order' } },
+              'application/json': { schema: { $ref: '#/components/schemas/Order' } },
+            },
+          },
+          '400': { description: 'Invalid ID supplied' },
+          '404': { description: 'Order not found' },
+        },
+      },
+      delete: {
+        tags: ['store'],
+        summary: 'Delete purchase order by ID',
+        description:
+          'For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors',
+        operationId: 'deleteOrder',
+        parameters: [
+          {
+            name: 'orderId',
+            in: 'path',
+            description: 'ID of the order that needs to be deleted',
+            required: true,
+            schema: { type: 'integer', format: 'int64' },
+          },
+        ],
+        responses: {
+          '400': { description: 'Invalid ID supplied' },
+          '404': { description: 'Order not found' },
+        },
+      },
+    },
+    '/user': {
+      post: {
+        tags: ['user'],
+        summary: 'Create user',
+        description: 'This can only be done by the logged in user.',
+        operationId: 'createUser',
+        requestBody: {
+          description: 'Created user object',
+          content: {
+            'application/json': { schema: { $ref: '#/components/schemas/User' } },
+            'application/xml': { schema: { $ref: '#/components/schemas/User' } },
+            'application/x-www-form-urlencoded': {
+              schema: { $ref: '#/components/schemas/User' },
+            },
+          },
+        },
+        responses: {
+          default: {
+            description: 'successful operation',
+            content: {
+              'application/json': { schema: { $ref: '#/components/schemas/User' } },
+              'application/xml': { schema: { $ref: '#/components/schemas/User' } },
+            },
+          },
+        },
+      },
+    },
+    '/user/createWithList': {
+      post: {
+        tags: ['user'],
+        summary: 'Creates list of users with given input array',
+        description: 'Creates list of users with given input array',
+        operationId: 'createUsersWithListInput',
+        requestBody: {
+          content: {
+            'application/json': {
+              schema: { type: 'array', items: { $ref: '#/components/schemas/User' } },
+            },
+          },
+        },
+        responses: {
+          '200': {
+            description: 'Successful operation',
+            content: {
+              'application/xml': { schema: { $ref: '#/components/schemas/User' } },
+              'application/json': { schema: { $ref: '#/components/schemas/User' } },
+            },
+          },
+          default: { description: 'successful operation' },
+        },
+      },
+    },
+    '/user/login': {
+      get: {
+        tags: ['user'],
+        summary: 'Logs user into the system',
+        description: '',
+        operationId: 'loginUser',
+        parameters: [
+          {
+            name: 'username',
+            in: 'query',
+            description: 'The user name for login',
+            required: false,
+            schema: { type: 'string' },
+          },
+          {
+            name: 'password',
+            in: 'query',
+            description: 'The password for login in clear text',
+            required: false,
+            schema: { type: 'string' },
+          },
+        ],
+        responses: {
+          '200': {
+            description: 'successful operation',
+            headers: {
+              'X-Rate-Limit': {
+                description: 'calls per hour allowed by the user',
+                schema: { type: 'integer', format: 'int32' },
+              },
+              'X-Expires-After': {
+                description: 'date in UTC when token expires',
+                schema: { type: 'string', format: 'date-time' },
+              },
+            },
+            content: {
+              'application/xml': { schema: { type: 'string' } },
+              'application/json': { schema: { type: 'string' } },
+            },
+          },
+          '400': { description: 'Invalid username/password supplied' },
+        },
+      },
+    },
+    '/user/logout': {
+      get: {
+        tags: ['user'],
+        summary: 'Logs out current logged in user session',
+        description: '',
+        operationId: 'logoutUser',
+        parameters: [],
+        responses: { default: { description: 'successful operation' } },
+      },
+    },
+    '/user/{username}': {
+      get: {
+        tags: ['user'],
+        summary: 'Get user by user name',
+        description: '',
+        operationId: 'getUserByName',
+        parameters: [
+          {
+            name: 'username',
+            in: 'path',
+            description: 'The name that needs to be fetched. Use user1 for testing. ',
+            required: true,
+            schema: { type: 'string' },
+          },
+        ],
+        responses: {
+          '200': {
+            description: 'successful operation',
+            content: {
+              'application/xml': { schema: { $ref: '#/components/schemas/User' } },
+              'application/json': { schema: { $ref: '#/components/schemas/User' } },
+            },
+          },
+          '400': { description: 'Invalid username supplied' },
+          '404': { description: 'User not found' },
+        },
+      },
+      put: {
+        tags: ['user'],
+        summary: 'Update user',
+        description: 'This can only be done by the logged in user.',
+        operationId: 'updateUser',
+        parameters: [
+          {
+            name: 'username',
+            in: 'path',
+            description: 'name that need to be deleted',
+            required: true,
+            schema: { type: 'string' },
+          },
+        ],
+        requestBody: {
+          description: 'Update an existent user in the store',
+          content: {
+            'application/json': { schema: { $ref: '#/components/schemas/User' } },
+            'application/xml': { schema: { $ref: '#/components/schemas/User' } },
+            'application/x-www-form-urlencoded': {
+              schema: { $ref: '#/components/schemas/User' },
+            },
+          },
+        },
+        responses: { default: { description: 'successful operation' } },
+      },
+      delete: {
+        tags: ['user'],
+        summary: 'Delete user',
+        description: 'This can only be done by the logged in user.',
+        operationId: 'deleteUser',
+        parameters: [
+          {
+            name: 'username',
+            in: 'path',
+            description: 'The name that needs to be deleted',
+            required: true,
+            schema: { type: 'string' },
+          },
+        ],
+        responses: {
+          '400': { description: 'Invalid username supplied' },
+          '404': { description: 'User not found' },
+        },
+      },
+    },
+  },
+  components: {
+    schemas: {
+      Order: {
+        type: 'object',
+        properties: {
+          id: { type: 'integer', format: 'int64', example: 10 },
+          petId: { type: 'integer', format: 'int64', example: 198772 },
+          quantity: { type: 'integer', format: 'int32', example: 7 },
+          shipDate: { type: 'string', format: 'date-time' },
+          status: {
+            type: 'string',
+            description: 'Order Status',
+            example: 'approved',
+            enum: ['placed', 'approved', 'delivered'],
+          },
+          complete: { type: 'boolean' },
+        },
+        xml: { name: 'order' },
+      },
+      Customer: {
+        type: 'object',
+        properties: {
+          id: { type: 'integer', format: 'int64', example: 100000 },
+          username: { type: 'string', example: 'fehguy' },
+          address: {
+            type: 'array',
+            xml: { name: 'addresses', wrapped: true },
+            items: { $ref: '#/components/schemas/Address' },
+          },
+        },
+        xml: { name: 'customer' },
+      },
+      Address: {
+        type: 'object',
+        properties: {
+          street: { type: 'string', example: '437 Lytton' },
+          city: { type: 'string', example: 'Palo Alto' },
+          state: { type: 'string', example: 'CA' },
+          zip: { type: 'string', example: '94301' },
+        },
+        xml: { name: 'address' },
+      },
+      Category: {
+        type: 'object',
+        properties: {
+          id: { type: 'integer', format: 'int64', example: 1 },
+          name: { type: 'string', example: 'Dogs' },
+        },
+        xml: { name: 'category' },
+      },
+      User: {
+        type: 'object',
+        properties: {
+          id: { type: 'integer', format: 'int64', example: 10 },
+          username: { type: 'string', example: 'theUser' },
+          firstName: { type: 'string', example: 'John' },
+          lastName: { type: 'string', example: 'James' },
+          email: { type: 'string', example: 'john@email.com' },
+          password: { type: 'string', example: '12345' },
+          phone: { type: 'string', example: '12345' },
+          userStatus: {
+            type: 'integer',
+            description: 'User Status',
+            format: 'int32',
+            example: 1,
+          },
+        },
+        xml: { name: 'user' },
+      },
+      Tag: {
+        type: 'object',
+        properties: {
+          id: { type: 'integer', format: 'int64' },
+          name: { type: 'string' },
+        },
+        xml: { name: 'tag' },
+      },
+      Pet: {
+        required: ['name', 'photoUrls'],
+        type: 'object',
+        properties: {
+          id: { type: 'integer', format: 'int64', example: 10 },
+          name: { type: 'string', example: 'doggie' },
+          category: { $ref: '#/components/schemas/Category' },
+          photoUrls: {
+            type: 'array',
+            xml: { wrapped: true },
+            items: { type: 'string', xml: { name: 'photoUrl' } },
+          },
+          tags: {
+            type: 'array',
+            xml: { wrapped: true },
+            items: { $ref: '#/components/schemas/Tag' },
+          },
+          status: {
+            type: 'string',
+            description: 'pet status in the store',
+            enum: ['available', 'pending', 'sold'],
+          },
+        },
+        xml: { name: 'pet' },
+      },
+      ApiResponse: {
+        type: 'object',
+        properties: {
+          code: { type: 'integer', format: 'int32' },
+          type: { type: 'string' },
+          message: { type: 'string' },
+        },
+        xml: { name: '##default' },
+      },
+    },
+    requestBodies: {
+      Pet: {
+        description: 'Pet object that needs to be added to the store',
+        content: {
+          'application/json': { schema: { $ref: '#/components/schemas/Pet' } },
+          'application/xml': { schema: { $ref: '#/components/schemas/Pet' } },
+        },
+      },
+      UserArray: {
+        description: 'List of user object',
+        content: {
+          'application/json': {
+            schema: { type: 'array', items: { $ref: '#/components/schemas/User' } },
+          },
+        },
+      },
+    },
+    securitySchemes: {
+      petstore_auth: {
+        type: 'oauth2',
+        flows: {
+          implicit: {
+            authorizationUrl: 'https://petstore3.swagger.io/oauth/authorize',
+            scopes: { 'write:pets': 'modify pets in your account', 'read:pets': 'read your pets' },
+          },
+        },
+      },
+      api_key: { type: 'apiKey', name: 'api_key', in: 'header' },
+    },
+  },
+};
index 2e65bd5e3ef8bf94817f676264fca9b5a2e1cb57..42240ee3237e237ef88a911fe8b4806de20c1d2c 100644 (file)
@@ -17,6 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { OpenAPIV3 } from 'openapi-types';
 import { throwGlobalError } from '../helpers/error';
 import { getJSON } from '../helpers/request';
 import { WebApi } from '../types/types';
@@ -41,3 +42,7 @@ export function fetchResponseExample(domain: string, action: string): Promise<We
     throwGlobalError
   );
 }
+
+export function fetchOpenAPI(): Promise<OpenAPIV3.Document> {
+  return getJSON('/api/v2/api-docs').catch(throwGlobalError);
+}
index f4924fa10b7b9186c30eafe5cdb4219dc4f3a6de..a974c4bfba12d6341e6174b4403a3d2f88f6fed5 100644 (file)
@@ -47,6 +47,7 @@ const TEMP_PAGELIST_WITH_NEW_BACKGROUND = [
   '/project/extension/securityreport/securityreport',
   '/projects',
   '/project/information',
+  '/web_api_v2',
 ];
 
 const TEMP_PAGELIST_WITH_NEW_BACKGROUND_WHITE = ['/tutorials'];
index 1664ad7abcc56fb7103c0f174cfed8e8ca784ff8..def7cb998a24003415158ac3ad687f04181ff1f2 100644 (file)
@@ -59,6 +59,7 @@ import settingsRoutes from '../../apps/settings/routes';
 import systemRoutes from '../../apps/system/routes';
 import tutorialsRoutes from '../../apps/tutorials/routes';
 import usersRoutes from '../../apps/users/routes';
+import webAPIRoutesV2 from '../../apps/web-api-v2/routes';
 import webAPIRoutes from '../../apps/web-api/routes';
 import webhooksRoutes from '../../apps/webhooks/routes';
 import { translate } from '../../helpers/l10n';
@@ -231,6 +232,7 @@ export default function startReactApp(
                             <Route path="sonarlint/auth" element={<SonarLintConnection />} />
 
                             {webAPIRoutes()}
+                            {webAPIRoutesV2()}
 
                             {renderComponentRoutes()}
 
index 6a3379d9de9e03e4f7ac63ab6dd7d5c78d4d6406..76f60db5dea31d83bec4123bec074b435aa083ce 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { Accordion, BasicSeparator, TextMuted } from 'design-system';
+import { BasicSeparator, BorderlessAccordion, TextMuted } from 'design-system';
 import * as React from 'react';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { isDiffMetric } from '../../../helpers/measures';
@@ -134,7 +134,7 @@ export function QualityGatePanelSection(props: QualityGatePanelSectionProps) {
     <>
       {isApplication ? (
         <>
-          <Accordion
+          <BorderlessAccordion
             ariaLabel={toggleLabel}
             onClick={toggle}
             open={!collapsed}
@@ -155,7 +155,7 @@ export function QualityGatePanelSection(props: QualityGatePanelSectionProps) {
             <BasicSeparator />
 
             {renderFailedConditions()}
-          </Accordion>
+          </BorderlessAccordion>
 
           {(!isLastStatus || collapsed) && <BasicSeparator />}
         </>
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/WebApiApp.tsx b/server/sonar-web/src/main/js/apps/web-api-v2/WebApiApp.tsx
new file mode 100644 (file)
index 0000000..94d840d
--- /dev/null
@@ -0,0 +1,111 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import styled from '@emotion/styled';
+import { DeferredSpinner, LargeCenteredLayout, PageContentFontWrapper, Title } from 'design-system';
+import { omit } from 'lodash';
+import React, { useMemo } from 'react';
+import { Helmet } from 'react-helmet-async';
+import { useLocation } from 'react-router-dom';
+import { translate } from '../../helpers/l10n';
+import ApiInformation from './components/ApiInformation';
+import ApiSidebar from './components/ApiSidebar';
+import { useOpenAPI } from './queries';
+import { URL_DIVIDER, dereferenceSchema } from './utils';
+
+export default function WebApiApp() {
+  const { data, isLoading } = useOpenAPI();
+  const location = useLocation();
+  const activeApi = location.hash.replace('#', '').split(URL_DIVIDER);
+
+  const apis = useMemo(() => {
+    if (!data) {
+      return [];
+    }
+    return Object.entries(dereferenceSchema(data).paths ?? {}).reduce(
+      (acc, [name, methods]) => [
+        ...acc,
+        ...Object.entries(
+          omit(methods, 'summary', '$ref', 'description', 'servers', 'parameters') ?? {}
+        ).map(([method, info]) => ({ name, method, info })),
+      ],
+      []
+    );
+  }, [data]);
+
+  const activeData =
+    activeApi.length > 1 &&
+    apis.find((api) => api.name === activeApi[0] && api.method === activeApi[1]);
+
+  return (
+    <LargeCenteredLayout>
+      <PageContentFontWrapper className="sw-body-sm">
+        <Helmet defer={false} title={translate('api_documentation.page')} />
+        <DeferredSpinner loading={isLoading}>
+          {data && (
+            <div className="sw-w-full sw-flex">
+              <NavContainer aria-label={translate('api_documentation.page')} className="sw--mx-2">
+                <div className="sw-w-[300px] lg:sw-w-[390px] sw-mx-2">
+                  <ApiSidebar
+                    docInfo={data.info}
+                    apisList={apis.map(({ name, method, info }) => ({
+                      method,
+                      name,
+                      info,
+                    }))}
+                  />
+                </div>
+              </NavContainer>
+              <main
+                className="sw-relative sw-ml-12 sw-flex-1 sw-overflow-y-auto sw-py-6"
+                style={{ height: 'calc(100vh - 160px)' }}
+              >
+                <DeferredSpinner loading={isLoading}>
+                  {!activeData && (
+                    <>
+                      <Title>{translate('about')}</Title>
+                      <p>{data.info.description}</p>
+                    </>
+                  )}
+                  {data && activeData && (
+                    <ApiInformation
+                      apiUrl={data.servers?.[0]?.url ?? ''}
+                      name={activeData.name}
+                      data={activeData.info}
+                      method={activeData.method}
+                    />
+                  )}
+                </DeferredSpinner>
+              </main>
+            </div>
+          )}
+        </DeferredSpinner>
+      </PageContentFontWrapper>
+    </LargeCenteredLayout>
+  );
+}
+
+const NavContainer = styled.nav`
+  scrollbar-gutter: stable;
+  overflow-y: auto;
+  overflow-x: hidden;
+  height: calc(100vh - 160px);
+  padding-top: 1.5rem;
+  padding-bottom: 1.5rem;
+`;
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/__tests__/WebApiApp-it.tsx b/server/sonar-web/src/main/js/apps/web-api-v2/__tests__/WebApiApp-it.tsx
new file mode 100644 (file)
index 0000000..e3a795c
--- /dev/null
@@ -0,0 +1,162 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+import WebApiServiceMock from '../../../api/mocks/WebApiServiceMock';
+import { renderApp } from '../../../helpers/testReactTestingUtils';
+import { byRole, byTestId, byText } from '../../../helpers/testSelector';
+import WebApiApp from '../WebApiApp';
+
+const handler = new WebApiServiceMock();
+
+const ui = {
+  search: byRole('searchbox'),
+  title: byRole('link', { name: 'Swagger Petstore - OpenAPI 3.0 1.0.17' }),
+  searchClear: byRole('button', { name: 'clear' }),
+  apiScopePet: byRole('button', { name: 'pet' }),
+  apiScopeStore: byRole('button', { name: 'store' }),
+  apiScopeUser: byRole('button', { name: 'user' }),
+  apiSidebarItem: byTestId('js-subnavigation-item'),
+  requestBody: byText('api_documentation.v2.request_subheader.request_body'),
+  queryParameter: byRole('list', { name: 'api_documentation.v2.request_subheader.query' }).byRole(
+    'listitem'
+  ),
+  pathParameter: byRole('list', { name: 'api_documentation.v2.request_subheader.path' }).byRole(
+    'listitem'
+  ),
+  requestHeader: byRole('list', { name: 'api_documentation.v2.request_subheader.header' }).byRole(
+    'listitem'
+  ),
+  response: byRole('list', { name: 'api_documentation.v2.response_header' }).byRole('listitem'),
+};
+
+beforeEach(() => {
+  handler.reset();
+});
+
+it('should search apis', async () => {
+  const user = userEvent.setup();
+  renderWebApiApp();
+  expect(await ui.apiScopePet.find()).toBeInTheDocument();
+  expect(ui.apiScopeStore.get()).toBeInTheDocument();
+  expect(ui.apiScopeUser.get()).toBeInTheDocument();
+  expect(ui.apiSidebarItem.queryAll()).toHaveLength(0);
+
+  await user.click(ui.apiScopePet.get());
+  expect(ui.apiSidebarItem.getAll()).toHaveLength(8);
+  await user.click(ui.apiScopeStore.get());
+  expect(ui.apiSidebarItem.getAll()).toHaveLength(12);
+  await user.click(ui.apiScopeUser.get());
+  expect(ui.apiSidebarItem.getAll()).toHaveLength(19);
+
+  await user.type(ui.search.get(), 'put');
+  const putItems = ui.apiSidebarItem.getAll();
+  expect(putItems).toHaveLength(3);
+  expect(ui.apiScopePet.get()).toBeInTheDocument();
+  expect(ui.apiScopeStore.query()).not.toBeInTheDocument();
+  expect(ui.apiScopeUser.get()).toBeInTheDocument();
+  expect(putItems[0]).toHaveTextContent('PUTUpdate an existing pet');
+  expect(putItems[1]).toHaveTextContent('POSTCreates list of users with given input array');
+
+  await user.click(ui.searchClear.get());
+
+  expect(ui.apiScopeStore.get()).toBeInTheDocument();
+  expect(ui.apiSidebarItem.getAll().length).toBeGreaterThan(3);
+});
+
+it('should navigate between apis', async () => {
+  const user = userEvent.setup();
+  renderWebApiApp();
+  await user.click(await ui.apiScopePet.find());
+
+  await user.click(ui.apiSidebarItem.getAt(0));
+  expect(await screen.findByText('/api/v3/pet')).toBeInTheDocument();
+  expect(await screen.findByText('Update an existing pet by Id')).toBeInTheDocument();
+  expect(ui.response.getAll()).toHaveLength(4);
+  expect(ui.queryParameter.query()).not.toBeInTheDocument();
+  expect(ui.pathParameter.query()).not.toBeInTheDocument();
+  expect(ui.requestHeader.query()).not.toBeInTheDocument();
+  expect(ui.requestBody.get()).toBeInTheDocument();
+  expect(ui.response.byRole('button').getAt(0)).toHaveAttribute('aria-expanded', 'true');
+  expect(ui.response.byRole('button').getAt(2)).toHaveAttribute('aria-expanded', 'false');
+  expect(ui.response.getAt(0)).toHaveTextContent('200Successful operation');
+  expect(ui.response.getAt(0)).toHaveTextContent('"name": "string"');
+  expect(ui.response.getAt(1)).not.toHaveTextContent('no_data');
+  await user.click(ui.response.byRole('button').getAt(2));
+  expect(ui.response.getAt(0)).toHaveTextContent('"name": "string"');
+  expect(ui.response.getAt(1)).toHaveTextContent('no_data');
+  await user.click(ui.response.byRole('button').getAt(0));
+  expect(ui.response.getAt(0)).not.toHaveTextContent('"name": "string"');
+  expect(ui.response.getAt(1)).toHaveTextContent('no_data');
+
+  await user.click(ui.apiSidebarItem.getAt(2));
+  expect(await screen.findByText('/api/v3/pet/findByStatus')).toBeInTheDocument();
+  expect(ui.response.byRole('button').getAt(0)).toHaveAttribute('aria-expanded', 'true');
+  expect(ui.response.byRole('button').getAt(2)).toHaveAttribute('aria-expanded', 'false');
+  expect(ui.queryParameter.get()).toBeInTheDocument();
+  expect(ui.pathParameter.query()).not.toBeInTheDocument();
+  expect(ui.requestHeader.query()).not.toBeInTheDocument();
+  expect(ui.requestBody.query()).not.toBeInTheDocument();
+  expect(ui.queryParameter.getAll()).toHaveLength(1);
+  expect(ui.queryParameter.getAt(0)).toHaveTextContent('status ["available","pending","sold"]');
+  expect(ui.queryParameter.getAt(0)).not.toHaveTextContent('default: available');
+  await user.click(ui.queryParameter.byRole('button').getAt(0));
+  expect(ui.queryParameter.getAt(0)).toHaveTextContent('default: available');
+  await user.click(ui.queryParameter.byRole('button').getAt(0));
+  expect(ui.queryParameter.getAt(0)).not.toHaveTextContent('default: available');
+
+  await user.click(ui.apiSidebarItem.getAt(5));
+  expect(await screen.findByText('/api/v3/pet/{petId}')).toBeInTheDocument();
+  expect(ui.queryParameter.getAll()).toHaveLength(2);
+  expect(ui.pathParameter.getAll()).toHaveLength(1);
+  expect(ui.requestHeader.query()).not.toBeInTheDocument();
+  expect(ui.requestBody.query()).not.toBeInTheDocument();
+  expect(ui.pathParameter.getAt(0)).toHaveTextContent('petId integer (int64)required');
+  expect(ui.pathParameter.getAt(0)).not.toHaveTextContent('ID of pet that needs to be updated');
+  await user.click(ui.pathParameter.byRole('button').getAt(0));
+  expect(ui.pathParameter.getAt(0)).toHaveTextContent('ID of pet that needs to be updated');
+  expect(ui.queryParameter.getAt(0)).toHaveTextContent('deprecated');
+  expect(ui.queryParameter.getAt(1)).not.toHaveTextContent('deprecated');
+  await user.click(ui.queryParameter.byRole('button').getAt(1));
+  expect(ui.queryParameter.getAt(1)).toHaveTextContent('max: 5min: -1example: 3');
+});
+
+it('should show About page', async () => {
+  const user = userEvent.setup();
+  renderWebApiApp();
+  expect(await screen.findByText('about')).toBeInTheDocument();
+  expect(
+    screen.getByText('This is a sample Pet Store Server based on the OpenAPI 3.0 specification.', {
+      exact: false,
+    })
+  ).toBeInTheDocument();
+  await user.click(ui.apiScopePet.get());
+  await user.click(ui.apiSidebarItem.getAt(0));
+  expect(screen.queryByText('about')).not.toBeInTheDocument();
+  await user.click(ui.title.get());
+  expect(await screen.findByText('about')).toBeInTheDocument();
+});
+
+function renderWebApiApp() {
+  // eslint-disable-next-line testing-library/no-unnecessary-act
+  renderApp('web-api-v2', <WebApiApp />);
+}
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/web-api-v2/__tests__/utils-test.ts
new file mode 100644 (file)
index 0000000..de2113e
--- /dev/null
@@ -0,0 +1,239 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import { dereferenceSchema, mapOpenAPISchema } from '../utils';
+
+it('should dereference schema', () => {
+  expect(
+    dereferenceSchema({
+      openapi: '3.0.1',
+      info: {
+        title: 'SonarQube Web API',
+        version: '1.0.0 beta',
+      },
+      paths: {
+        '/test': {
+          delete: {
+            responses: {
+              '200': {
+                description: 'Internal Server Error',
+                content: {
+                  'application/json': {
+                    schema: {
+                      $ref: '#/components/schemas/Test',
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+        '/test/{first}': {
+          get: {
+            parameters: [
+              {
+                name: 'first',
+                in: 'path',
+                description: '1',
+                schema: {
+                  $ref: '#/components/schemas/NestedTest',
+                },
+              },
+              {
+                name: 'second',
+                in: 'query',
+                description: '2',
+                schema: {
+                  type: 'string',
+                },
+              },
+            ],
+            responses: {
+              '200': {
+                description: 'Internal Server Error',
+                content: {
+                  'application/json': {
+                    schema: {
+                      $ref: '#/components/schemas/NestedTest',
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      },
+      components: {
+        schemas: {
+          NestedTest: {
+            type: 'object',
+            properties: {
+              test: {
+                type: 'array',
+                items: {
+                  $ref: '#/components/schemas/Test',
+                },
+              },
+            },
+          },
+          Test: {
+            type: 'string',
+          },
+        },
+      },
+    })
+  ).toStrictEqual({
+    openapi: '3.0.1',
+    info: {
+      title: 'SonarQube Web API',
+      version: '1.0.0 beta',
+    },
+    paths: {
+      '/test': {
+        delete: {
+          responses: {
+            '200': {
+              description: 'Internal Server Error',
+              content: {
+                'application/json': {
+                  schema: {
+                    type: 'string',
+                  },
+                },
+              },
+            },
+          },
+        },
+      },
+      '/test/{first}': {
+        get: {
+          parameters: [
+            {
+              name: 'first',
+              in: 'path',
+              description: '1',
+              schema: {
+                type: 'object',
+                properties: {
+                  test: {
+                    type: 'array',
+                    items: {
+                      type: 'string',
+                    },
+                  },
+                },
+              },
+            },
+            {
+              name: 'second',
+              in: 'query',
+              description: '2',
+              schema: {
+                type: 'string',
+              },
+            },
+          ],
+          responses: {
+            '200': {
+              description: 'Internal Server Error',
+              content: {
+                'application/json': {
+                  schema: {
+                    type: 'object',
+                    properties: {
+                      test: {
+                        type: 'array',
+                        items: {
+                          type: 'string',
+                        },
+                      },
+                    },
+                  },
+                },
+              },
+            },
+          },
+        },
+      },
+    },
+    components: {
+      schemas: {
+        NestedTest: {
+          type: 'object',
+          properties: {
+            test: {
+              type: 'array',
+              items: {
+                type: 'string',
+              },
+            },
+          },
+        },
+        Test: {
+          type: 'string',
+        },
+      },
+    },
+  });
+});
+
+it('should map open api response schema', () => {
+  expect(
+    mapOpenAPISchema({
+      type: 'object',
+      properties: {
+        str: {
+          type: 'string',
+        },
+        int: {
+          type: 'integer',
+          format: 'int32',
+        },
+        num: {
+          type: 'number',
+          format: 'double',
+        },
+        bool: {
+          type: 'boolean',
+        },
+      },
+    })
+  ).toStrictEqual({
+    str: 'string',
+    int: 'integer (int32)',
+    num: 'number (double)',
+    bool: 'boolean',
+  });
+
+  expect(
+    mapOpenAPISchema({
+      type: 'array',
+      items: {
+        type: 'string',
+      },
+    })
+  ).toStrictEqual(['string']);
+
+  expect(
+    mapOpenAPISchema({
+      type: 'string',
+      enum: ['GREEN', 'YELLOW', 'RED'],
+    })
+  ).toStrictEqual(['GREEN', 'YELLOW', 'RED']);
+});
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiInformation.tsx b/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiInformation.tsx
new file mode 100644 (file)
index 0000000..1b5d683
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import classNames from 'classnames';
+import { Badge, SubHeading, Title } from 'design-system';
+import { OpenAPIV3 } from 'openapi-types';
+import React from 'react';
+import { ExcludeReferences } from '../types';
+import { getApiEndpointKey, getMethodClassName } from '../utils';
+import ApiParameters from './ApiParameters';
+import ApiResponses from './ApiResponses';
+
+interface Props<T extends {}> {
+  data: ExcludeReferences<OpenAPIV3.OperationObject<T>>;
+  apiUrl: string;
+  name: string;
+  method: string;
+}
+
+export default function ApiInformation<T extends {}>({ name, data, method, apiUrl }: Props<T>) {
+  return (
+    <>
+      {data.summary && <Title>{data.summary}</Title>}
+      <SubHeading>
+        <Badge className={classNames('sw-align-middle sw-mr-4', getMethodClassName(method))}>
+          {method}
+        </Badge>
+        {apiUrl.replace(/.*(?=\/api)/, '') + name}
+      </SubHeading>
+      {data.description && <div>{data.description}</div>}
+      <div className="sw-grid sw-grid-cols-2 sw-gap-4 sw-mt-4">
+        <div>
+          <ApiParameters key={getApiEndpointKey(name, method)} data={data} />
+        </div>
+        <div>
+          <ApiResponses key={getApiEndpointKey(name, method)} responses={data.responses} />
+        </div>
+      </div>
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiParameters.tsx b/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiParameters.tsx
new file mode 100644 (file)
index 0000000..d3f82f4
--- /dev/null
@@ -0,0 +1,132 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { Accordion, Badge, SubHeading, SubTitle, TextMuted } from 'design-system';
+import { groupBy } from 'lodash';
+import { OpenAPIV3 } from 'openapi-types';
+import React from 'react';
+import { translate } from '../../../helpers/l10n';
+import { ExcludeReferences } from '../types';
+import { mapOpenAPISchema } from '../utils';
+import ApiResponseSchema from './ApiResponseSchema';
+
+interface Props {
+  data: ExcludeReferences<OpenAPIV3.OperationObject>;
+}
+
+export default function ApiParameters({ data }: Props) {
+  const [openParameters, setOpenParameters] = React.useState<string[]>([]);
+
+  const toggleParameter = (name: string) => {
+    if (openParameters.includes(name)) {
+      setOpenParameters(openParameters.filter((n) => n !== name));
+    } else {
+      setOpenParameters([...openParameters, name]);
+    }
+  };
+
+  const getSchemaType = (schema: ExcludeReferences<OpenAPIV3.SchemaObject>) => {
+    const mappedSchema = mapOpenAPISchema(schema);
+
+    return typeof mappedSchema === 'object' ? JSON.stringify(mappedSchema) : mappedSchema;
+  };
+
+  const requestBody = data.requestBody?.content;
+
+  return (
+    <>
+      <SubTitle>{translate('api_documentation.v2.parameter_header')}</SubTitle>
+      {!requestBody && !data.parameters?.length && <TextMuted text={translate('no_data')} />}
+      {requestBody && (
+        <div>
+          <SubHeading>
+            {translate('api_documentation.v2.request_subheader.request_body')}
+          </SubHeading>
+          <ApiResponseSchema content={requestBody} />
+        </div>
+      )}
+      {Object.entries(groupBy(data.parameters, (p) => p.in)).map(([group, parameters]) => (
+        <div key={group}>
+          <SubHeading id={`api-parameters-${group}`}>
+            {translate(`api_documentation.v2.request_subheader.${group}`)}
+          </SubHeading>
+          <ul aria-labelledby={`api-parameters-${group}`}>
+            {parameters.map((parameter) => {
+              return (
+                <Accordion
+                  className="sw-mt-2 sw-mb-4"
+                  key={parameter.name}
+                  header={
+                    <div>
+                      {parameter.name}{' '}
+                      {parameter.schema && (
+                        <TextMuted
+                          className="sw-inline sw-ml-2"
+                          text={getSchemaType(parameter.schema)}
+                        />
+                      )}
+                      {parameter.required && (
+                        <Badge className="sw-ml-2">{translate('required')}</Badge>
+                      )}
+                      {parameter.deprecated && (
+                        <Badge variant="deleted" className="sw-ml-2">
+                          {translate('deprecated')}
+                        </Badge>
+                      )}
+                    </div>
+                  }
+                  data={parameter.name}
+                  onClick={toggleParameter}
+                  open={openParameters.includes(parameter.name)}
+                >
+                  <div>{parameter.description}</div>
+                  {parameter.schema?.maximum && (
+                    <TextMuted
+                      className="sw-mt-2 sw-block"
+                      text={`${translate('max')}: ${parameter.schema?.maximum}`}
+                    />
+                  )}
+                  {parameter.schema?.minimum && (
+                    <TextMuted
+                      className="sw-mt-2 sw-block"
+                      text={`${translate('min')}: ${parameter.schema?.minimum}`}
+                    />
+                  )}
+                  {parameter.example !== undefined && (
+                    <TextMuted
+                      className="sw-mt-2 sw-block"
+                      text={`${translate('example')}: ${parameter.example}`}
+                    />
+                  )}
+                  {parameter.schema?.default !== undefined && (
+                    <TextMuted
+                      className="sw-mt-2 sw-block"
+                      text={`${translate('default')}: ${parameter.schema?.default}`}
+                    />
+                  )}
+                </Accordion>
+              );
+            })}
+          </ul>
+        </div>
+      ))}
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiResponseSchema.tsx b/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiResponseSchema.tsx
new file mode 100644 (file)
index 0000000..bd9ec2d
--- /dev/null
@@ -0,0 +1,47 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { CodeSnippet, TextMuted } from 'design-system';
+import { OpenAPIV3 } from 'openapi-types';
+import React, { HtmlHTMLAttributes } from 'react';
+import { translate } from '../../../helpers/l10n';
+import { ExcludeReferences } from '../types';
+import { mapOpenAPISchema } from '../utils';
+
+interface Props extends HtmlHTMLAttributes<HTMLDivElement> {
+  content?: Exclude<ExcludeReferences<OpenAPIV3.ResponseObject>['content'], undefined>;
+}
+
+export default function ApiResponseSchema(props: Props) {
+  const { content, ...other } = props;
+  if (!content || !content['application/json']?.schema) {
+    return <TextMuted text={translate('no_data')} />;
+  }
+
+  return (
+    <CodeSnippet
+      language="json"
+      className="sw-p-6"
+      snippet={JSON.stringify(mapOpenAPISchema(content['application/json'].schema), null, 2)}
+      wrap
+      {...other}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiResponseTitle.tsx b/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiResponseTitle.tsx
new file mode 100644 (file)
index 0000000..3f58ba8
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { Badge } from 'design-system';
+import React from 'react';
+import { getResponseCodeClassName } from '../utils';
+
+interface Props {
+  code: string;
+  codeDescription: string;
+}
+
+export default function ApiResponseTitle({ code, codeDescription }: Props) {
+  return (
+    <div className="sw-flex sw-items-center sw-gap-2">
+      <Badge className={getResponseCodeClassName(code)}>{code}</Badge>
+      <span>{codeDescription}</span>
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiResponses.tsx b/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiResponses.tsx
new file mode 100644 (file)
index 0000000..931f633
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { Accordion, SubTitle } from 'design-system';
+import { OpenAPIV3 } from 'openapi-types';
+import React from 'react';
+import { translate } from '../../../helpers/l10n';
+import { ExcludeReferences } from '../types';
+import ApiResponseSchema from './ApiResponseSchema';
+import ApiResponseTitle from './ApiResponseTitle';
+
+interface Props {
+  responses?: ExcludeReferences<OpenAPIV3.ResponsesObject>;
+}
+
+export default function ApiResponses({ responses }: Props) {
+  const [openedResponses, setOpenedResponses] = React.useState<string[]>([
+    Object.keys(responses ?? {})[0],
+  ]);
+
+  const toggleParameter = (name: string) => {
+    if (openedResponses.includes(name)) {
+      setOpenedResponses(openedResponses.filter((n) => n !== name));
+    } else {
+      setOpenedResponses([...openedResponses, name]);
+    }
+  };
+
+  const titleId = 'api-responses';
+
+  return (
+    <>
+      <SubTitle id={titleId}>{translate('api_documentation.v2.response_header')}</SubTitle>
+      {!responses && <div>{translate('no_data')}</div>}
+      {responses && (
+        <ul aria-labelledby={titleId}>
+          {Object.entries(responses).map(([code, response]) => (
+            <Accordion
+              className="sw-mt-2"
+              key={code}
+              header={<ApiResponseTitle code={code} codeDescription={response.description} />}
+              data={code}
+              onClick={toggleParameter}
+              open={openedResponses.includes(code)}
+            >
+              <ApiResponseSchema content={response.content} />
+            </Accordion>
+          ))}
+        </ul>
+      )}
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiSidebar.tsx b/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiSidebar.tsx
new file mode 100644 (file)
index 0000000..8a3cc3e
--- /dev/null
@@ -0,0 +1,116 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import classNames from 'classnames';
+import { Badge, InputSearch, Link, SubnavigationAccordion, SubnavigationItem } from 'design-system';
+import { OpenAPIV3 } from 'openapi-types';
+import React, { useMemo, useState } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { translate } from '../../../helpers/l10n';
+import { URL_DIVIDER, getApiEndpointKey, getMethodClassName } from '../utils';
+
+interface Api {
+  name: string;
+  method: string;
+  info: OpenAPIV3.OperationObject;
+}
+interface Props {
+  docInfo: OpenAPIV3.InfoObject;
+  apisList: Api[];
+}
+
+export default function ApiSidebar({ apisList, docInfo }: Props) {
+  const [search, setSearch] = useState('');
+  const navigate = useNavigate();
+  const location = useLocation();
+  const activeApi = location.hash.replace('#', '').split(URL_DIVIDER);
+
+  const handleApiClick = (value: string) => {
+    navigate(`.#${value}`, { replace: true });
+  };
+
+  const lowerCaseSearch = search.toLowerCase();
+
+  const groupedList = useMemo(
+    () =>
+      apisList
+        .filter(
+          (api) =>
+            api.name.toLowerCase().includes(lowerCaseSearch) ||
+            api.method.toLowerCase().includes(lowerCaseSearch) ||
+            api.info.summary?.toLowerCase().includes(lowerCaseSearch)
+        )
+        .reduce<Record<string, Api[]>>((acc, api) => {
+          const subgroup = api.name.split('/')[1];
+          return {
+            ...acc,
+            [subgroup]: [...(acc[subgroup] ?? []), api],
+          };
+        }, {}),
+    [lowerCaseSearch, apisList]
+  );
+
+  return (
+    <>
+      <h1 className="sw-mb-2">
+        <Link to="." className="sw-text-[unset] sw-border-none">
+          {docInfo.title}
+          <Badge className="sw-align-middle sw-ml-2">{docInfo.version}</Badge>
+        </Link>
+      </h1>
+
+      <InputSearch
+        className="sw-w-full"
+        placeholder={translate('api_documentation.v2.search')}
+        onChange={setSearch}
+        clearIconAriaLabel={translate('clear')}
+        value={search}
+      />
+
+      {Object.entries(groupedList).map(([group, apis]) => (
+        <SubnavigationAccordion
+          initExpanded={apis.some(
+            ({ name, method }) => name === activeApi[0] && method === activeApi[1]
+          )}
+          className="sw-mt-2"
+          header={group}
+          key={group}
+          id={`web-api-${group}`}
+        >
+          {apis.map(({ method, name, info }) => (
+            <SubnavigationItem
+              active={name === activeApi[0] && method === activeApi[1]}
+              key={getApiEndpointKey(name, method)}
+              onClick={handleApiClick}
+              value={getApiEndpointKey(name, method)}
+            >
+              <div className="sw-flex sw-gap-2">
+                <Badge className={classNames('sw-self-center', getMethodClassName(method))}>
+                  {method.toUpperCase()}
+                </Badge>
+                <div>{info.summary ?? name}</div>
+              </div>
+              {info.deprecated && <Badge variant="deleted">{translate('deprecated')}</Badge>}
+            </SubnavigationItem>
+          ))}
+        </SubnavigationAccordion>
+      ))}
+    </>
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/queries.ts b/server/sonar-web/src/main/js/apps/web-api-v2/queries.ts
new file mode 100644 (file)
index 0000000..1b0bf90
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { useQuery } from '@tanstack/react-query';
+import { fetchOpenAPI } from '../../api/web-api';
+
+export const useOpenAPI = () => {
+  return useQuery(['open_api'], fetchOpenAPI, { staleTime: Infinity, cacheTime: Infinity });
+};
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/routes.tsx b/server/sonar-web/src/main/js/apps/web-api-v2/routes.tsx
new file mode 100644 (file)
index 0000000..394388b
--- /dev/null
@@ -0,0 +1,26 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import React from 'react';
+import { Route } from 'react-router-dom';
+import WebApiApp from './WebApiApp';
+
+const routes = () => <Route path="web_api_v2" element={<WebApiApp />} />;
+
+export default routes;
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/types.ts b/server/sonar-web/src/main/js/apps/web-api-v2/types.ts
new file mode 100644 (file)
index 0000000..a35f8f8
--- /dev/null
@@ -0,0 +1,37 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { OpenAPIV3 } from 'openapi-types';
+
+export type ExcludeReferences<T> = T extends OpenAPIV3.ReferenceObject
+  ? never
+  : T extends object
+  ? { [K in keyof T]: ExcludeReferences<T[K]> }
+  : T;
+
+export type DereferenceRecursive<T> = T extends object
+  ? T extends OpenAPIV3.ReferenceObject
+    ? ExcludeReferences<T>
+    : T extends Array<infer U>
+    ? Array<DereferenceRecursive<U>>
+    : {
+        [K in keyof T]: DereferenceRecursive<T[K]>;
+      }
+  : T;
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/utils.ts b/server/sonar-web/src/main/js/apps/web-api-v2/utils.ts
new file mode 100644 (file)
index 0000000..768b1b8
--- /dev/null
@@ -0,0 +1,109 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+import { mapValues } from 'lodash';
+import { OpenAPIV3 } from 'openapi-types';
+import { DereferenceRecursive, ExcludeReferences } from './types';
+
+export const URL_DIVIDER = '-';
+
+type ConvertedSchema = string | { [Key: string]: ConvertedSchema } | ConvertedSchema[];
+
+export const mapOpenAPISchema = (
+  schema: ExcludeReferences<OpenAPIV3.SchemaObject>
+): ConvertedSchema => {
+  if (schema.type === 'object') {
+    const result = { ...schema.properties };
+    return mapValues(result, (schema) => mapOpenAPISchema(schema)) as ConvertedSchema;
+  }
+  if (schema.type === 'array') {
+    return [mapOpenAPISchema(schema.items)];
+  }
+  if (schema.type === 'string' && schema.enum) {
+    return schema.enum as ConvertedSchema;
+  }
+  if (schema.format) {
+    return `${schema.type} (${schema.format})`;
+  }
+  return schema.type as string;
+};
+
+export const dereferenceSchema = (
+  document: OpenAPIV3.Document
+): ExcludeReferences<OpenAPIV3.Document> => {
+  const dereference = (ref: string) => {
+    const path = ref.replace('#/', '').split('/');
+    return path.reduce((acc: any, key) => {
+      if (key in acc) {
+        return acc[key];
+      }
+      return {};
+    }, document);
+  };
+
+  const dereferenceRecursive = <P>(val: P | OpenAPIV3.ReferenceObject): DereferenceRecursive<P> => {
+    if (typeof val === 'object' && val !== null) {
+      if ('$ref' in val) {
+        return dereferenceRecursive(dereference(val.$ref));
+      } else if (Array.isArray(val)) {
+        return val.map(dereferenceRecursive) as DereferenceRecursive<P>;
+      }
+      return mapValues(val, dereferenceRecursive) as DereferenceRecursive<P>;
+    }
+    return val as DereferenceRecursive<P>;
+  };
+
+  return dereferenceRecursive(document) as ExcludeReferences<OpenAPIV3.Document>;
+};
+
+export const getMethodClassName = (method: string): string => {
+  switch (method.toLowerCase()) {
+    case 'get':
+      return 'sw-bg-green-200';
+    case 'delete':
+      return 'sw-bg-red-200';
+    case 'post':
+      return 'sw-bg-blue-200';
+    case 'put':
+      return 'sw-bg-purple-200';
+    case 'patch':
+      return 'sw-bg-yellow-200';
+    default:
+      return 'sw-bg-gray-200';
+  }
+};
+
+export const getResponseCodeClassName = (code: string): string => {
+  switch (code[0]) {
+    case '1':
+      return 'sw-bg-blue-200';
+    case '2':
+      return 'sw-bg-green-200';
+    case '3':
+      return 'sw-bg-yellow-200';
+    case '4':
+    case '5':
+      return 'sw-bg-red-200';
+    default:
+      return 'sw-bg-gray-200';
+  }
+};
+
+export const getApiEndpointKey = (name: string, method: string) => `${name}${URL_DIVIDER}${method}`;
index 909ed7d284fb64446ed1e93f5a89ff01b85fef15..50ecf6de2b400e0f6fb201056b62b3ec45603591 100644 (file)
@@ -4982,6 +4982,7 @@ __metadata:
     jsdom: 21.1.1
     lodash: 4.17.21
     lunr: 2.3.9
+    openapi-types: 12.1.3
     path-browserify: 1.0.1
     postcss: 8.4.24
     postcss-calc: 9.0.1
@@ -10823,6 +10824,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"openapi-types@npm:12.1.3":
+  version: 12.1.3
+  resolution: "openapi-types@npm:12.1.3"
+  checksum: 7fa5547f87a58d2aa0eba6e91d396f42d7d31bc3ae140e61b5d60b47d2fd068b48776f42407d5a8da7280cf31195aa128c2fc285e8bb871d1105edee5647a0bb
+  languageName: node
+  linkType: hard
+
 "optionator@npm:^0.8.1":
   version: 0.8.2
   resolution: "optionator@npm:0.8.2"
index 28b58d317e5a10f9b1849001499f7927f5740a26..42629e26805ee56fdb38c1a8dd2d3457fb938317 100644 (file)
@@ -4,6 +4,7 @@
 #
 #------------------------------------------------------------------------------
 
+about=About
 action=Action
 actions=Actions
 active=Active
@@ -171,6 +172,7 @@ remove=Remove
 remove_x=Remove {0}
 rename=Rename
 replaces=Replaces
+required=Required
 reset_verb=Reset
 reset_to_default=Reset To Default
 reset_date=Reset dates
@@ -3670,6 +3672,13 @@ api_documentation.parameters=Parameters
 api_documentation.response_example=Response Example
 api_documentation.changelog=Changelog
 api_documentation.search=Search by name...
+api_documentation.v2.search=Search API...
+api_documentation.v2.parameter_header=Request
+api_documentation.v2.response_header=Response
+api_documentation.v2.request_subheader.query=Query Parameters
+api_documentation.v2.request_subheader.path=Path Parameters
+api_documentation.v2.request_subheader.header=Headers
+api_documentation.v2.request_subheader.request_body=Request Body
 
 
 #------------------------------------------------------------------------------