sdk-for-web icon indicating copy to clipboard operation
sdk-for-web copied to clipboard

๐Ÿš€ Feature: Allow passing in custom fetch imp

Open Fractal-Tess opened this issue 1 year ago โ€ข 2 comments

๐Ÿ”– Feature description

In the constructor of the client, it would be nice for users to be able to provide their own fetch implementations.

๐ŸŽค Pitch

Why?

Because frameworks like SvelteKit, allow prefetching data on the server and exposing the promise to the client. For this to work and to avoid re-fetching twice (on the server and the client), the appwrite SDK client needs to be able to receive the custom sveltekit fetch implementation. https://svelte.dev/docs/kit/load#Making-fetch-requests

๐Ÿ‘€ Have you spent some time to check if this issue has been raised before?

  • [x] I checked and didn't find similar issue

๐Ÿข Have you read the Code of Conduct?

Fractal-Tess avatar Dec 04 '24 15:12 Fractal-Tess

I'm open to creating a PR for this if the feature is desired.

Fractal-Tess avatar Dec 04 '24 15:12 Fractal-Tess

@Fractal-Tess i like the idea, are there any other implementations where this would be beneficial to the user than the one Svelte provides?

I would also like you to checkout our sdk generator repository. This repo is read only and any changes introduced are first made over there.

ChiragAgg5k avatar Jan 28 '25 08:01 ChiragAgg5k

@Fractal-Tess i like the idea, are there any other implementations where this would be beneficial to the user than the one Svelte provides?

I would also like you to checkout our sdk generator repository. This repo is read only and any changes introduced are first made over there.

I'm building a side project in Angular v20 and wanted to implement a custom HTTP cache for API requests. However, I noticed that the AppWrite web SDK's internal Client class uses the standard fetch method for HTTP calls, while Angular applications typically use Angular's HttpClient for requests.

To better integrate with Angular's ecosystem (and leverage features like interceptors and signals), I've created a custom implementation of the AppWrite Client class that uses Angular's HttpClient instead of fetch. I've started by writing a simple cache interceptor for Angular's HTTP requests, but I'd like to have a similar implementation internally the appwrite web sdk.

What do you think?

Here is the simple Client implementation:

@Injectable({
  providedIn: 'root',
})
class WrapClient extends Client {
  private readonly _httpClient = inject(HttpClient);

  constructor() {
    super();

    this.setEndpoint(appwriteConfig.endpoint); // Set your Appwrite endpoint
    this.setProject(appwriteConfig.projectId); // Set your Appwrite project ID
  }

  override async call(
    method: string,
    url: URL,
    headers?: { [key: string]: string },
    params?: Payload,
    responseType?: string,
  ): Promise<any> {
    const { uri, options } = this.prepareRequest(method, url, headers, params);

    try {
      // Create HttpHeaders from options headers
      let httpHeaders = new HttpHeaders();
      if (options.headers) {
        Object.entries(options.headers).forEach(([key, value]) => {
          httpHeaders = httpHeaders.set(key, value as string);
        });
      }

      let response: HttpResponse<any>;

      // Handle different response types
      if (responseType === 'arrayBuffer') {
        response = await firstValueFrom(
          this._httpClient.request(method.toUpperCase(), uri, {
            headers: httpHeaders,
            body: options.body,
            responseType: 'arraybuffer',
            observe: 'response',
            withCredentials: options.credentials === 'include',
          }),
        );
      } else {
        response = await firstValueFrom(
          this._httpClient.request<any>(method.toUpperCase(), uri, {
            headers: httpHeaders,
            body: options.body,
            responseType: 'json',
            observe: 'response',
            withCredentials: options.credentials === 'include',
          }),
        );
      }

      // Handle warnings from response headers
      const warnings = response.headers.get('x-appwrite-warning');
      if (warnings) {
        warnings
          .split(';')
          .forEach((warning: string) => console.warn('Warning: ' + warning));
      }

      // Handle cookie fallback
      const cookieFallback = response.headers.get('X-Fallback-Cookies');
      if (
        typeof window !== 'undefined' &&
        window.localStorage &&
        cookieFallback
      ) {
        window.console.warn(
          'Appwrite is using localStorage for session management. Increase your security by adding a custom domain as your API endpoint.',
        );
        window.localStorage.setItem('cookieFallback', cookieFallback);
      }

      // Return appropriate data based on response type
      if (responseType === 'arrayBuffer') {
        return response.body;
      }

      if (response.headers.get('content-type')?.includes('application/json')) {
        return response.body;
      }

      // For text responses, we need to handle them differently
      const textResponse = await firstValueFrom(
        this._httpClient.request(method.toUpperCase(), uri, {
          headers: httpHeaders,
          body: options.body,
          responseType: 'text',
          observe: 'response',
          withCredentials: options.credentials === 'include',
        }),
      );
      return {
        message: textResponse.body,
      };
    } catch (error) {
      if (error instanceof HttpErrorResponse) {
        // Handle HTTP errors
        const contentType = error.headers.get('content-type');

        if (
          contentType?.includes('application/json') ||
          responseType === 'arrayBuffer'
        ) {
          throw new AppwriteException(
            error.error?.message || error.message,
            error.status,
            error.error?.type,
            JSON.stringify(error.error),
          );
        }

        throw new AppwriteException(
          error.error?.message || error.message,
          error.status,
          error.error?.type,
          error.error?.message || error.message,
        );
      }

      // Handle CORS or other network errors
      if (error instanceof Error && error.message.includes('CORS')) {
        throw new AppwriteException(
          `Invalid Origin. Register your new client (${window.location.host}) as a new Web platform on your project console dashboard`,
          403,
          'forbidden',
          '',
        );
      }

      // Re-throw other errors
      throw error;
    }
  }
}

This can be used as a cache interceptor:

const cache = new Map<
  string,
  { response: HttpResponse<any>; timestamp: number }
>();

export function cacheInterceptor(
  req: HttpRequest<any>,
  next: HttpHandlerFn,
): Observable<HttpEvent<any>> {
  debugger;
  const cacheKey = `${req.method}:${req.urlWithParams}`;

  // Check if the request is cached
  if (cache.has(cacheKey)) {
    const cachedResponse = cache.get(cacheKey)!;
    const isExpired = Date.now() - cachedResponse.timestamp > 10000; // 10 seconds

    if (!isExpired) {
      return of(cachedResponse.response.clone());
    } else {
      cache.delete(cacheKey); // Remove expired cache
    }
  }

  // Proceed with the request
  return next(req).pipe(
    tap((event) => {
      if (event instanceof HttpResponse) {
        cache.set(cacheKey, { response: event, timestamp: Date.now() });
      }
    }),
  );
}

KevinValmo avatar Aug 01 '25 22:08 KevinValmo