go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/hooks/prpc_query/use_prpc_service_client.ts (about) 1 // Copyright 2024 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 { 16 useAuthState, 17 useGetAccessToken, 18 } from '@/common/components/auth_state_provider'; 19 import { PrpcClient } from '@/generic_libs/tools/prpc_client'; 20 import { Rpc } from '@/proto_utils/types'; 21 22 export interface PrpcServiceClientOptions<S, Params extends unknown[] = []> { 23 /** 24 * The host of the pRPC server. 25 */ 26 readonly host: string; 27 /** 28 * If true, use HTTP instead of HTTPS. Defaults to `false`. 29 */ 30 readonly insecure?: boolean; 31 /** 32 * A generic client implementation generated by ts-proto. 33 * e.g. `UserClientImpl`. If the constructor requires additional params, they 34 * can be provided via `usePrpcServiceClient(opts, ...params)`. 35 */ 36 readonly ClientImpl: Constructor<S, [Rpc, ...Params]> & { 37 readonly DEFAULT_SERVICE: string; 38 }; 39 } 40 41 /** 42 * A pRPC method with optional additional params. 43 */ 44 type Method<Req, Res, Params extends unknown[] = []> = ( 45 req: Req, 46 ...params: Params 47 ) => Res; 48 49 /** 50 * The request object from a pRPC method. 51 */ 52 type MethodReq<T> = T extends Method<infer Req, infer _Res> ? Req : never; 53 54 type PagedReq<Req> = Req extends { readonly pageToken: string } ? Req : never; 55 type PagedRes<Res> = Awaited<Res> extends { readonly nextPageToken: string } 56 ? Res 57 : never; 58 59 type MethodRes<T> = T extends Method<infer _Req, infer Res> ? Res : never; 60 61 type MethodParams<T> = T extends Method<infer _Req, infer _Res, infer Params> 62 ? Params 63 : never; 64 65 type MethodKeys<S, Req, Res, Params extends unknown[] = []> = keyof { 66 [MK in keyof S as S[MK] extends Method<Req, Res, Params> 67 ? MK 68 : never]: unknown; 69 }; 70 71 export type DecoratedMethod<MK, Req, Res, Params extends unknown[] = []> = { 72 (req: Req, ...params: Params): Res; 73 /** 74 * Builds a ReactQuery option that queries the method. The query key is 75 * consisted of 76 * `[userIdentity, 'prpc', serviceHost, serviceName, methodName, request]`. 77 */ 78 query( 79 req: Req, 80 ...params: Params 81 ): { 82 queryKey: [string, 'prpc', string, string, MK, Req]; 83 queryFn: () => Res; 84 }; 85 /** 86 * Builds a ReactQuery option that queries the paginated method. The next page 87 * param is automatically extracted from the previous response. The query key 88 * is consisted of 89 * `[userIdentity, 'prpc-paged', serviceHost, serviceName, methodName, request]`. 90 */ 91 queryPaged( 92 req: PagedRes<Res> extends Res ? PagedReq<Req> : never, 93 ...params: Params 94 ): { 95 queryKey: [string, 'prpc-paged', string, string, MK, Req]; 96 queryFn: (ctx: { pageParam?: string }) => Res; 97 getNextPageParam: (lastRes: Awaited<Res>) => string | null; 98 }; 99 }; 100 101 export type DecoratedClient<S> = { 102 // The request type has to be `any` because the argument type must be contra- 103 // variant when sub-typing a function. 104 // eslint-disable-next-line @typescript-eslint/no-explicit-any 105 [MK in keyof S]: S[MK] extends Method<any, any> 106 ? DecoratedMethod< 107 MK, 108 MethodReq<S[MK]>, 109 MethodRes<S[MK]>, 110 MethodParams<S[MK]> 111 > 112 : S[MK]; 113 }; 114 115 export interface CallOpt<MK, Request> { 116 readonly method: MK; 117 readonly request: Request; 118 } 119 120 /** 121 * Construct a decorated pRPC client with the users credentials. Add helper 122 * functions to each pRPC method to help generating ReactQuery options. 123 * 124 * Example: 125 * ```typescript 126 * // Constructs a client. 127 * const client = usePrpcClient({ 128 * // The host of the pRPC server. 129 * host: 'cr-buildbucket-dev.appspot.com', 130 * // The client implementation generated by ts-proto. 131 * ClientImpl: BuildsClientImpl, 132 * }) 133 * 134 * // Use the `.query` helper function to build a ReactQuery. 135 * const {data, isLoading, ...} = useQuery({ 136 * // `.query` is available on all pRPC methods. It takes the same parameters 137 * // as the method itself, and returns a ReactQuery options object with 138 * // `queryKey` and `queryFn` populated. 139 * ...client.GetBuild.query({ 140 * // A type checked request object. The type of the request object is 141 * // inferred from the supplied client implementation and method name. 142 * id: "1234", 143 * ... 144 * }), 145 * // Add or override query options if needed. 146 * select: (res) => {...}, 147 * ... 148 * }) 149 * 150 * // Use the `.queryPaged` helper function to build an infinite ReactQuery. 151 * const {data, isLoading, ...} = useInfiniteQuery({ 152 * // `.queryPaged` similar to `.query` except that 153 * // 1. it is only available on pRPC methods that have 'pageToken' in the 154 * // request object and 'nextPageToken' in the response object, and 155 * // 2. in addition to `queryKey` and `queryFn`, it also populates 156 * // `getNextPageParam`, making it suitable for `useInfiniteQuery`. 157 * ...client.SearchBuilds.queryPaged({ 158 * // A type checked request object. The type of the request object is 159 * // inferred from the supplied client implementation and method name. 160 * id: "1234", 161 * ... 162 * }), 163 * // Add or override query options if needed. 164 * select: (res) => {...}, 165 * ... 166 * }) 167 * ``` 168 */ 169 export function usePrpcServiceClient< 170 S extends object, 171 Params extends unknown[], 172 >(opts: PrpcServiceClientOptions<S, Params>, ...params: Params) { 173 const { host, insecure, ClientImpl } = opts; 174 175 const { identity } = useAuthState(); 176 const getAuthToken = useGetAccessToken(); 177 const client = new ClientImpl( 178 new PrpcClient({ host, insecure, getAuthToken }), 179 ...params, 180 ); 181 182 return new Proxy(client, { 183 get(target, p, receiver) { 184 const mk = p as MethodKeys<S, unknown, unknown>; 185 const value = Reflect.get(target, mk, receiver); 186 if (typeof value !== 'function') { 187 return value; 188 } 189 const fn = (...args: unknown[]) => value.apply(target, args); 190 fn.query = (req: object, ...params: unknown[]) => ({ 191 queryKey: [identity, 'prpc', host, ClientImpl.DEFAULT_SERVICE, mk, req], 192 queryFn: () => fn(req, ...params), 193 }); 194 fn.queryPaged = (req: object, ...params: unknown[]) => ({ 195 queryKey: [ 196 identity, 197 'prpc-paged', 198 host, 199 ClientImpl.DEFAULT_SERVICE, 200 mk, 201 req, 202 ], 203 queryFn: ({ pageParam = '' }) => 204 fn(pageParam ? { ...req, pageToken: pageParam } : req, ...params), 205 // Return `null` when the next page token is an empty string so it get 206 // treated as no next page. 207 getNextPageParam: ({ nextPageToken = '' }) => nextPageToken || null, 208 }); 209 return fn; 210 }, 211 }) as DecoratedClient<S>; 212 }