go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/web/rpcexplorer/src/views/method.tsx (about)

     1  // Copyright 2022 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  import { useEffect, useMemo, useRef, useState } from 'react';
    16  import { useParams, useSearchParams } from 'react-router-dom';
    17  
    18  import Alert from '@mui/material/Alert';
    19  import Box from '@mui/material/Box';
    20  import Button from '@mui/material/Button';
    21  import FormControlLabel from '@mui/material/FormControlLabel';
    22  import FormGroup from '@mui/material/FormGroup';
    23  import Grid from '@mui/material/Grid';
    24  import LinearProgress from '@mui/material/LinearProgress';
    25  import Link from '@mui/material/Link';
    26  import Switch from '@mui/material/Switch';
    27  import Tooltip from '@mui/material/Tooltip';
    28  
    29  import { useGlobals } from '../context/globals';
    30  
    31  import { AuthMethod, AuthSelector } from '../components/auth_selector';
    32  import { Doc } from '../components/doc';
    33  import { ErrorAlert } from '../components/error_alert';
    34  import { ExecuteIcon } from '../components/icons';
    35  import { OAuthError } from '../data/oauth';
    36  
    37  import { RequestEditor, RequestEditorRef } from '../components/request_editor';
    38  import { ResponseEditor } from '../components/response_editor';
    39  
    40  import { generateTraceID } from '../data/prpc';
    41  
    42  
    43  interface TraceInfo {
    44    duration: number;
    45    traceID: string;
    46    traceURL: string;
    47  }
    48  
    49  
    50  const Method = () => {
    51    const { serviceName, methodName } = useParams();
    52    const [searchParams, setSearchParams] = useSearchParams();
    53    const { descriptors, tokenClient } = useGlobals();
    54    const [authMethod, setAuthMethod] = useState(AuthMethod.load());
    55    const [tracingOn, setTracingOn] = useState(false);
    56    const [running, setRunning] = useState(false);
    57    const [response, setResponse] = useState('');
    58    const [traceInfo, setTraceInfo] = useState<TraceInfo | null>(null);
    59    const [error, setError] = useState<Error | null>(null);
    60  
    61    // Request editor is used via imperative methods since it can be too sluggish
    62    // to update on key presses otherwise.
    63    const requestEditor = useRef<RequestEditorRef>(null);
    64  
    65    // Initial request body can be passed via `request` query parameter.
    66    // Pretty-print it if it is a valid JSON. Memo this to avoid reparsing
    67    // potentially large JSON all the time.
    68    let initialRequest = searchParams.get('request') || '{}';
    69    initialRequest = useMemo(() => {
    70      try {
    71        return JSON.stringify(JSON.parse(initialRequest), null, 2);
    72      } catch {
    73        return initialRequest;
    74      }
    75    }, [initialRequest]);
    76  
    77    // Persist changes to `authMethod` in the local storage.
    78    useEffect(() => AuthMethod.store(authMethod), [authMethod]);
    79  
    80    // Find the method descriptor. It will be used for auto-completion and for
    81    // actually invoking the method.
    82    const svc = descriptors.service(serviceName ?? 'unknown');
    83    if (svc === undefined) {
    84      return (
    85        <Alert severity='error'>
    86          Service <b>{serviceName ?? 'unknown'}</b> is not
    87          registered in the server.
    88        </Alert>
    89      );
    90    }
    91    const method = svc.method(methodName ?? 'unknown');
    92    if (method === undefined) {
    93      return (
    94        <Alert severity='error'>
    95          Method <b>{methodName ?? 'unknown'}</b> is not a part of
    96          <b>{serviceName ?? 'unknown'}</b> service.
    97        </Alert>
    98      );
    99    }
   100  
   101    const invokeMethod = () => {
   102      if (!requestEditor.current) {
   103        return;
   104      }
   105  
   106      // Try to get a parsed JSON request from the editor. Catch bad JSON errors.
   107      let parsedReq: object;
   108      try {
   109        parsedReq = requestEditor.current.prepareRequest();
   110      } catch (err) {
   111        if (err instanceof Error) {
   112          setError(err);
   113        } else {
   114          setError(new Error(`${err}`));
   115        }
   116        return;
   117      }
   118  
   119      // Use compact request serialization (strip spaces etc).
   120      const normalizedReq = JSON.stringify(parsedReq);
   121  
   122      // Update the current location to allow copy-pasting this request via URI.
   123      setSearchParams((params) => {
   124        if (normalizedReq != '{}') {
   125          params.set('request', normalizedReq);
   126        } else {
   127          params.delete('request');
   128        }
   129        return params;
   130      }, { replace: true });
   131  
   132      // Deactivate the UI while the request is running.
   133      setRunning(true);
   134      setTraceInfo(null);
   135      setError(null);
   136  
   137      // Prepare trace ID if asked to trace the request.
   138      const traceID = tracingOn ? generateTraceID() : '';
   139      let started = Date.now();
   140  
   141      // Grabs the authentication header and invokes the method.
   142      const authAndInvoke = async () => {
   143        let authorization = '';
   144        if (tokenClient.sessionState != 'loggedout') {
   145          if (authMethod == AuthMethod.OAuth) {
   146            authorization = `Bearer ${await tokenClient.accessToken()}`;
   147          }
   148        }
   149        started = Date.now(); // restart the timer after getting a token
   150        return await method.invoke(normalizedReq, authorization, traceID);
   151      };
   152      authAndInvoke()
   153          .then((response) => {
   154            setResponse(response);
   155            setError(null);
   156          })
   157          .catch((error) => {
   158            // Canceled OAuth flow is a user-initiated error, don't show it.
   159            if (!(error instanceof OAuthError && error.cancelled)) {
   160              setResponse('');
   161              setError(error);
   162            }
   163          })
   164          .finally(() => {
   165            // Always show tracing info if asked, even on errors.
   166            if (traceID) {
   167              setTraceInfo({
   168                duration: Date.now() - started,
   169                traceID: traceID,
   170                traceURL: 'https://console.cloud.google.com/' +
   171                    `traces/list?tid=${traceID}`,
   172              });
   173            }
   174            // Reactivate the UI.
   175            setRunning(false);
   176          });
   177    };
   178  
   179    return (
   180      <Grid container spacing={2}>
   181        <Grid item xs={12}>
   182          <Doc markdown={method.doc} />
   183        </Grid>
   184  
   185        <Grid item xs={12}>
   186          <RequestEditor
   187            ref={requestEditor}
   188            requestType={descriptors.message(method.requestType)}
   189            defaultValue={initialRequest}
   190            readOnly={running}
   191            onInvokeMethod={invokeMethod}
   192          />
   193        </Grid>
   194  
   195        <Grid item xs={2}>
   196          <Button
   197            variant='outlined'
   198            disabled={running}
   199            onClick={invokeMethod}
   200            endIcon={<ExecuteIcon />}>
   201            Execute
   202          </Button>
   203        </Grid>
   204  
   205        <Grid item xs={8}>
   206          <Box>
   207            <AuthSelector
   208              selected={authMethod}
   209              onChange={setAuthMethod}
   210              disabled={running}
   211              anonOnly={tokenClient.sessionState == 'loggedout'}
   212            />
   213          </Box>
   214        </Grid>
   215  
   216        <Grid item xs={2}>
   217          <FormGroup>
   218            <Tooltip title={
   219              'Attach Cloud Trace trace ID to the request. ' +
   220              'Only works if the backend is configured to upload tracing data ' +
   221              'to Cloud Trace.'
   222            }>
   223              <FormControlLabel control={
   224                <Switch
   225                  checked={tracingOn}
   226                  onChange={(_, checked) => setTracingOn(checked)}
   227                />
   228              } label="Trace" />
   229            </Tooltip>
   230          </FormGroup>
   231        </Grid>
   232  
   233        {traceInfo &&
   234          <Grid item xs={12}>
   235            <Alert variant="outlined" icon={false} severity="info">
   236              {`Done in ${traceInfo.duration} ms. Trace ID is `}
   237              <Link target="_blank" rel="noreferrer" href={traceInfo.traceURL}>
   238                {traceInfo.traceID}
   239              </Link>
   240              {'.'}
   241            </Alert>
   242          </Grid>
   243        }
   244        {error &&
   245          <Grid item xs={12}>
   246            <ErrorAlert error={error} />
   247          </Grid>
   248        }
   249  
   250        {running &&
   251          <Grid item xs={12}>
   252            <LinearProgress />
   253          </Grid>
   254        }
   255  
   256        <Grid item xs={12}>
   257          <ResponseEditor value={response} />
   258        </Grid>
   259      </Grid>
   260    );
   261  };
   262  
   263  
   264  export default Method;