]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-21054 Add Request Body details for PATCH and POST in WEB API v2 Doc
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>
Fri, 17 Nov 2023 15:49:31 +0000 (16:49 +0100)
committersonartech <sonartech@sonarsource.com>
Tue, 21 Nov 2023 20:03:02 +0000 (20:03 +0000)
server/sonar-web/src/main/js/api/mocks/data/web-api.ts
server/sonar-web/src/main/js/apps/web-api-v2/__tests__/WebApiApp-it.tsx
server/sonar-web/src/main/js/apps/web-api-v2/components/ApiParameters.tsx
server/sonar-web/src/main/js/apps/web-api-v2/components/ApiRequestParameters.tsx [new file with mode: 0644]

index 1e5b475ed784b742c2b5381f708335b9b69c5bd0..76373b917fe7592a87b023d386ddf204288a25fe 100644 (file)
@@ -659,7 +659,7 @@ export const openApiTestData: OpenAPIV3.Document = {
         type: 'object',
         properties: {
           id: { type: 'integer', format: 'int64', example: 10 },
-          name: { type: 'string', example: 'doggie' },
+          name: { type: 'string', minLength: 3, maxLength: 100, example: 'doggie' },
           category: { $ref: '#/components/schemas/Category' },
           photoUrls: {
             type: 'array',
@@ -675,6 +675,7 @@ export const openApiTestData: OpenAPIV3.Document = {
             type: 'string',
             description: 'pet status in the store',
             enum: ['available', 'pending', 'sold'],
+            deprecated: true,
           },
         },
         xml: { name: 'pet' },
index 2ab10d3d5bda69711314636cb5d7b699c2be851b..97bd6d7629b2e36c25392d75ad86f8a9a8c1de83 100644 (file)
@@ -46,6 +46,9 @@ const ui = {
   requestHeader: byRole('list', { name: 'api_documentation.v2.request_subheader.header' }).byRole(
     'listitem',
   ),
+  requestBodyParameter: byRole('list', {
+    name: 'api_documentation.v2.request_subheader.request_body',
+  }).byRole('listitem'),
   response: byRole('list', { name: 'api_documentation.v2.response_header' }).byRole('listitem'),
 };
 
@@ -96,6 +99,22 @@ it('should navigate between apis', async () => {
   expect(ui.pathParameter.query()).not.toBeInTheDocument();
   expect(ui.requestHeader.query()).not.toBeInTheDocument();
   expect(ui.requestBody.get()).toBeInTheDocument();
+  expect(ui.requestBodyParameter.getAll()).toHaveLength(6);
+  expect(ui.requestBodyParameter.byRole('button').getAt(0)).toHaveAttribute(
+    'aria-expanded',
+    'false',
+  );
+  await user.click(ui.requestBodyParameter.byRole('button').getAt(0));
+  expect(ui.requestBodyParameter.byRole('button').getAt(0)).toHaveAttribute(
+    'aria-expanded',
+    'true',
+  );
+  expect(ui.requestBodyParameter.getAt(0)).toHaveTextContent('name requiredmax: 100min: 3');
+  await user.click(ui.requestBodyParameter.byRole('button').getAt(0));
+  expect(ui.requestBodyParameter.byRole('button').getAt(0)).toHaveAttribute(
+    'aria-expanded',
+    'false',
+  );
   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');
@@ -138,6 +157,10 @@ it('should navigate between apis', async () => {
   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');
+
+  await user.click(ui.apiSidebarItem.getAt(7));
+  expect(await screen.findByText('/api/v3/pet/{petId}/uploadImage')).toBeInTheDocument();
+  expect(screen.getByText('no_data')).toBeInTheDocument();
 });
 
 it('should show About page', async () => {
index d3f82f4a84c509ec6904ae5623d43c4d72b677b9..6116519884117dc470cff2a4b6c8efed992afe0c 100644 (file)
@@ -25,6 +25,7 @@ import React from 'react';
 import { translate } from '../../../helpers/l10n';
 import { ExcludeReferences } from '../types';
 import { mapOpenAPISchema } from '../utils';
+import ApiRequestBodyParameters from './ApiRequestParameters';
 import ApiResponseSchema from './ApiResponseSchema';
 
 interface Props {
@@ -53,15 +54,6 @@ export default function ApiParameters({ data }: Props) {
   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}`}>
@@ -103,7 +95,7 @@ export default function ApiParameters({ data }: Props) {
                       text={`${translate('max')}: ${parameter.schema?.maximum}`}
                     />
                   )}
-                  {parameter.schema?.minimum && (
+                  {typeof parameter.schema?.minimum === 'number' && (
                     <TextMuted
                       className="sw-mt-2 sw-block"
                       text={`${translate('min')}: ${parameter.schema?.minimum}`}
@@ -127,6 +119,16 @@ export default function ApiParameters({ data }: Props) {
           </ul>
         </div>
       ))}
+      {!requestBody && !data.parameters?.length && <TextMuted text={translate('no_data')} />}
+      {requestBody && (
+        <div>
+          <SubHeading id="api_documentation.v2.request_subheader.request_body">
+            {translate('api_documentation.v2.request_subheader.request_body')}
+          </SubHeading>
+          <ApiResponseSchema content={requestBody} />
+          <ApiRequestBodyParameters content={requestBody} />
+        </div>
+      )}
     </>
   );
 }
diff --git a/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiRequestParameters.tsx b/server/sonar-web/src/main/js/apps/web-api-v2/components/ApiRequestParameters.tsx
new file mode 100644 (file)
index 0000000..1888bd7
--- /dev/null
@@ -0,0 +1,112 @@
+/*
+ * 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, TextMuted } from 'design-system';
+import { isEmpty } from 'lodash';
+import { OpenAPIV3 } from 'openapi-types';
+import React from 'react';
+import { translate } from '../../../helpers/l10n';
+import { ExcludeReferences } from '../types';
+
+interface Props {
+  content?: Exclude<ExcludeReferences<OpenAPIV3.ResponseObject>['content'], undefined>;
+}
+
+export default function ApiRequestBodyParameters({ content }: Readonly<Props>) {
+  const [openParameters, setOpenParameters] = React.useState<string[]>([]);
+
+  const toggleParameter = (parameter: string) => {
+    if (openParameters.includes(parameter)) {
+      setOpenParameters(openParameters.filter((n) => n !== parameter));
+    } else {
+      setOpenParameters([...openParameters, parameter]);
+    }
+  };
+
+  const schema =
+    content &&
+    (content['application/json']?.schema || content['application/merge-patch+json']?.schema);
+
+  if (!schema?.properties || schema?.type !== 'object' || isEmpty(schema?.properties)) {
+    return null;
+  }
+
+  const parameters = schema.properties;
+  const required = schema.required ?? [];
+
+  const orderedKeys = Object.keys(parameters).sort((a, b) => {
+    if (required?.includes(a) && !required?.includes(b)) {
+      return -1;
+    }
+    if (!required?.includes(a) && required?.includes(b)) {
+      return 1;
+    }
+    return 0;
+  });
+
+  return (
+    <ul aria-labelledby="api_documentation.v2.request_subheader.request_body">
+      {orderedKeys.map((key) => {
+        return (
+          <Accordion
+            className="sw-mt-2 sw-mb-4"
+            key={key}
+            header={
+              <div>
+                {key}{' '}
+                {schema.required?.includes(key) && (
+                  <Badge className="sw-ml-2">{translate('required')}</Badge>
+                )}
+                {parameters[key].deprecated && (
+                  <Badge variant="deleted" className="sw-ml-2">
+                    {translate('deprecated')}
+                  </Badge>
+                )}
+              </div>
+            }
+            data={key}
+            onClick={() => toggleParameter(key)}
+            open={openParameters.includes(key)}
+          >
+            <div>{parameters[key].description}</div>
+            {parameters[key].maxLength && (
+              <TextMuted
+                className="sw-mt-2 sw-block"
+                text={`${translate('max')}: ${parameters[key].maxLength}`}
+              />
+            )}
+            {typeof parameters[key].minLength === 'number' && (
+              <TextMuted
+                className="sw-mt-2 sw-block"
+                text={`${translate('min')}: ${parameters[key].minLength}`}
+              />
+            )}
+            {parameters[key].default !== undefined && (
+              <TextMuted
+                className="sw-mt-2 sw-block"
+                text={`${translate('default')}: ${parameters[key].default}`}
+              />
+            )}
+          </Accordion>
+        );
+      })}
+    </ul>
+  );
+}