dappsnation / akita-ng-fire

Akita ❤️ Angular 🔥 Firebase
MIT License
131 stars 27 forks source link

Feature Request: Type Safe functions. #141

Closed wSedlacek closed 3 years ago

wSedlacek commented 4 years ago

In the latest update the callFunction() function was added to the public API. This function as it stands is not Type Safe. I feel like we could go one step further and offer a FunctionsService that provides a Type Safe solution (particularly useful in mono repos)

Below is an example of what a service like this would look like.

@Injectable({ providedIn: 'root' })
export class FunctionsService<Functions extends Record<string, (arg: any) => any>> {
  constructor(private readonly functions: AngularFireFunctions) {}

  /**
   * @description calls cloud function
   * @param name of the cloud function
   * @param params you want to set
   */
  public functionFactory<FunctionName extends keyof Functions>(
    name: FunctionName
  ): (
    params: Parameters<Functions[FunctionName]>[0]
  ) => Promise<ReturnType<Functions[FunctionName]>> {
    return async (params) => {
      const callableFunction = this.functions.httpsCallable(name.toString());

      return callableFunction(params).toPromise();
    };
  }
}

It would be consumed like so.

export interface VoteParams {
  snackID: string;
}

export type VoteReturns = void;

export interface VoteFunctions {
  vote(params: VoteParams): VoteReturns;
  [other: string]: (args: any) => unknown;
}

@Injectable({ providedIn: 'root' })
@CollectionConfig({ path: 'votes' })
export class VotesService extends CollectionService<VotesState> {
  constructor(
    private readonly functions: FunctionsService<VoteFunctions>
  ) {
    super(store);
  }

  public readonly sendVote = this.functions.functionFactory('vote');
}

Then the cloud function might be setup like so.

export const vote = https.onCall(async ({ snackID }: Partial<VoteParams>, context): Promise<VoteReturns> => {});
fritzschoff commented 4 years ago

Hey, thanks for suggestion. I will look into it and have talk with @GrandSchtroumpf who is the owner of this lib.

wSedlacek commented 4 years ago

Here is another example that supports an interfaces can just specify params with no return value (which from my experience tends to be most used use case)

Note: I have adjust the naming a bit to be more reflective of what each part actually does ie. FunctionFactoryService

type Serializable = string | number | boolean | null | void | SerializableObject | Serializable[];
interface SerializableObject {
  [key: string]: Serializable;
}

type Params<T extends ((params: Serializable) => unknown) | Serializable | any> = T extends (
  params: infer I
) => any
  ? I extends Serializable
    ? I
    : void
  : T;

type Returns<T extends ((params: unknown) => Serializable | any) | any> = T extends (
  params: any
) => infer I
  ? I extends Serializable
    ? I
    : void
  : void;

@Injectable({ providedIn: 'root' })
export class FunctionFactoryService<
  Functions extends Record<string, ((arg: Serializable) => Serializable) | Serializable | any>
> {
  constructor(private readonly functions: AngularFireFunctions) {}

  /**
   * @description Factory to create a type safe call of a cloud function
   * @param name of the cloud function
   * @param params of the cloud function
   */
  public create<FunctionName extends keyof Functions>(
    name: FunctionName
  ): (params: Params<Functions[FunctionName]>) => Promise<Returns<Functions[FunctionName]>> {
    return async (params) => {
      const callableFunction = this.functions.httpsCallable(name.toString());

      return callableFunction(params).toPromise();
    };
  }
}
export interface VoteParams {
  snackID: string;
}

export interface VoteFunctions {
  vote: VoteParams;
  foo(): number;
  bar(params: string): number;
  baz(params: string): void;
}

@Injectable({ providedIn: 'root' })
@CollectionConfig({ path: 'votes' })
export class VotesService extends CollectionService<VotesState> {
  constructor(
    protected readonly store: VotesStore,
    private readonly functionFactory: FunctionFactoryService<VoteFunctions>
  ) {
    super(store);
  }

  // (property) VotesService.voteFor: (params: VoteParams) => Promise<void>
  public readonly voteFor = this.functionFactory.create('vote');

  // (property) VotesService.foo: (params: void) => Promise<number>
  public readonly foo = this.functionFactory.create('foo');

  // (property) VotesService.bar: (params: string) => Promise<number>
  public readonly bar = this.functionFactory.create('bar');

  // (property) VotesService.baz: (params: string) => Promise<void>
  public readonly baz = this.functionFactory.create('baz');
}
GrandSchtroumpf commented 4 years ago

Hmm I wouldn't create a full service for this, especially not for typing. I would just improve the callFunction bind :

export async function callFunction<
 C extends Record<string, (...args: any) => any>,
 N extends Extract<keyof C, string>
>(
  functions: AngularFireFunctions,
  name: N,
  params?: Parameters<C[N]>
): Promise<ReturnType<C[N]>> {
    const callFunction = functions.httpsCallable(name);
    return callFunction(params).toPromise();
}

The code above is not 100% perfect (params become array & you need to specify the name in the angle brackets). But it's a good base to find the right type interface.

wSedlacek commented 4 years ago

After having time to reflect on this I think this might be an upstream issue and that a OR to make @angular/fire/funtions type safe may be better.