Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(trpc): allow to pass custom headers to trpc client #441

Merged
merged 1 commit into from
May 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions apps/trpc-app-e2e-playwright/tests/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,25 @@ describe('tRPC Demo App', () => {
).toContain(/Analog + tRPC/i);
});

test<TRPCTestContext>(`If user enters the first note the note should be stored
successfully and listed in the notes array`, async (ctx) => {
test<TRPCTestContext>(`
If user enters the first note the note should be storedsuccessfully and listed in the notes array.
Still unauthorized the user should not be able to delete the note and the error should be displayed.
After the users clicks the Login button and gets authorized, deleting the note again should work successfully,
and the error should disappear.
`, async (ctx) => {
await ctx.notesPage.typeNote(notes.first.note);

await ctx.notesPage.addNote();
expect(await ctx.notesPage.notes().elementHandles()).toHaveLength(1);

await ctx.notesPage.removeNote(0);
expect(await ctx.notesPage.notes().elementHandles()).toHaveLength(1);
expect(await ctx.notesPage.getDeleteErrorCount()).toBe(1);

await ctx.notesPage.toggleLogin();
await ctx.notesPage.removeNote(0);
await page.waitForSelector('.no-notes');
expect(await ctx.notesPage.notes().elementHandles()).toHaveLength(0);
expect(await ctx.notesPage.getDeleteErrorCount()).toBe(0);
});
});
9 changes: 8 additions & 1 deletion apps/trpc-app-e2e-playwright/tests/fixtures/notes.po.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { Page } from 'playwright';
export class NotesPage {
constructor(readonly page: Page) {}

async toggleLogin() {
await this.page.getByTestId('loginBtn').click();
}

async typeNote(note: string) {
await this.page.getByTestId('newNoteInput').fill(note);
}
Expand All @@ -16,7 +20,10 @@ export class NotesPage {
await this.waitForTrpcResponse(
this.page.getByTestId('removeNoteAtIndexBtn' + index).click()
);
await this.page.waitForSelector('.no-notes');
}

async getDeleteErrorCount() {
return this.page.locator('[data-testid="deleteError"]').count();
}

notes() {
Expand Down
46 changes: 42 additions & 4 deletions apps/trpc-app/src/app/pages/index.page.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { injectTRPCClient } from '../../trpc-client';
import {
ChangeDetectionStrategy,
Component,
effect,
signal,
} from '@angular/core';
import { injectTRPCClient, tRPCHeaders } from '../../trpc-client';
import { AsyncPipe, DatePipe, JsonPipe, NgFor, NgIf } from '@angular/common';
import { FormsModule, NgForm } from '@angular/forms';
import { Note } from '../../note';
import { shareReplay, Subject, switchMap, take } from 'rxjs';
import { catchError, of, shareReplay, Subject, switchMap, take } from 'rxjs';
import { waitFor } from '@analogjs/trpc';
import { TRPCClientError } from '@trpc/client';
import { AppRouter } from '../../server/trpc/routers';

const inputTw =
'focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:outline-0 block w-full appearance-none rounded-lg px-3 py-2 transition-colors text-base leading-tight md:text-sm bg-black/[.05] dark:bg-zinc-50/10 focus:bg-white dark:focus:bg-dark placeholder:text-zinc-500 dark:placeholder:text-zinc-400 contrast-more:border contrast-more:border-current';
Expand All @@ -28,6 +35,9 @@ const btnTw =
src="/assets/spartan.svg"
/>
</div>
<button data-testid="loginBtn" (click)="toggleLogin()" class="${btnTw}">
{{ loggedIn() ? 'Log out' : 'Log in' }}
</button>
<form class="py-2 flex items-center" #f="ngForm" (ngSubmit)="addPost(f)">
<label class="sr-only" for="newNote"> Note </label>
<input
Expand Down Expand Up @@ -89,6 +99,9 @@ const btnTw =
</div>
</div>
</ng-template>
<p data-testid="deleteError" *ngIf="error()?.message">
{{ error()?.message }}
</p>
`,
})
export default class HomeComponent {
Expand All @@ -99,10 +112,23 @@ export default class HomeComponent {
shareReplay(1)
);
public newNote = '';
public loggedIn = signal(false);
public error = signal<TRPCClientError<AppRouter> | undefined>(undefined);

constructor() {
void waitFor(this.notes$);
this.triggerRefresh$.next();

effect(
() =>
tRPCHeaders.mutate(
(h) =>
(h['authorization'] = this.loggedIn()
? 'Bearer authToken'
: undefined)
),
{ allowSignalWrites: true }
);
}

public noteTrackBy = (index: number, note: Note) => {
Expand All @@ -114,6 +140,7 @@ export default class HomeComponent {
form.form.markAllAsTouched();
return;
}
console.log(tRPCHeaders());
this._trpc.note.create
.mutate({ title: this.newNote })
.pipe(take(1))
Expand All @@ -123,9 +150,20 @@ export default class HomeComponent {
}

public removePost(id: number) {
this.error.set(undefined);
this._trpc.note.remove
.mutate({ id })
.pipe(take(1))
.pipe(
take(1),
catchError((e) => {
this.error.set(e);
return of(null);
})
)
.subscribe(() => this.triggerRefresh$.next());
}

public toggleLogin() {
this.loggedIn.update((loggedIn) => !loggedIn);
}
}
10 changes: 9 additions & 1 deletion apps/trpc-app/src/server/trpc/context.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { inferAsyncReturnType } from '@trpc/server';
import { getRequestHeader, H3Event } from 'h3';

/**
* Creates context for an incoming request
* @link https://trpc.io/docs/context
*/
export const createContext = () => ({});
export const createContext = async (event: H3Event) => {
// Create your context based on the request object
// Will be available as `ctx` in all your resolvers
const authorization = getRequestHeader(event, 'authorization');
return {
hasAuth: authorization && authorization.split(' ')[1]?.length > 0,
};
};
export type Context = inferAsyncReturnType<typeof createContext>;
4 changes: 2 additions & 2 deletions apps/trpc-app/src/server/trpc/routers/notes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';
import { protectedProcedure, publicProcedure, router } from '../trpc';
import { Note } from '../../../note';

let noteId = 0;
Expand All @@ -19,7 +19,7 @@ export const noteRouter = router({
})
),
list: publicProcedure.query(() => notes),
remove: publicProcedure
remove: protectedProcedure
.input(
z.object({
id: z.number(),
Expand Down
13 changes: 12 additions & 1 deletion apps/trpc-app/src/server/trpc/trpc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { initTRPC } from '@trpc/server';
import { initTRPC, TRPCError } from '@trpc/server';
import { Context } from './context';
import superjson from 'superjson';

Expand All @@ -9,5 +9,16 @@ const t = initTRPC.context<Context>().create({
* Unprotected procedure
**/
export const publicProcedure = t.procedure;

const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.hasAuth) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx,
});
});

export const protectedProcedure = t.procedure.use(isAuthed);
export const router = t.router;
export const middleware = t.middleware;
13 changes: 7 additions & 6 deletions apps/trpc-app/src/trpc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { createTrpcClient } from '@analogjs/trpc';
import { inject } from '@angular/core';
import superjson from 'superjson';

export const { provideTRPCClient, tRPCClient } = createTrpcClient<AppRouter>({
url: 'http://localhost:4205/api/trpc',
options: {
transformer: superjson,
},
});
export const { provideTRPCClient, tRPCClient, tRPCHeaders } =
createTrpcClient<AppRouter>({
url: 'http://localhost:4205/api/trpc',
options: {
transformer: superjson,
},
});

export function injectTRPCClient() {
return inject(tRPCClient);
Expand Down
13 changes: 11 additions & 2 deletions packages/trpc/src/lib/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { InjectionToken, Provider, TransferState } from '@angular/core';
import { InjectionToken, Provider, signal, TransferState } from '@angular/core';
import 'isomorphic-fetch';
import { httpBatchLink } from '@trpc/client';
import { httpBatchLink, HttpBatchLinkOptions } from '@trpc/client';
import { AnyRouter } from '@trpc/server';
import { transferStateLink } from './links/transfer-state-link';
import {
Expand All @@ -10,10 +10,12 @@ import {
} from './cache-state';
import { createTRPCRxJSProxyClient } from './trpc-rxjs-proxy';
import { CreateTRPCClientOptions } from '@trpc/client/src/createTRPCUntypedClient';
import { HTTPHeaders } from '@trpc/client/src/links/types';

export type TrpcOptions<T extends AnyRouter> = {
url: string;
options?: Partial<CreateTRPCClientOptions<T>>;
batchLinkOptions?: Omit<HttpBatchLinkOptions, 'url' | 'headers'>;
};

export type TrpcClient<AppRouter extends AnyRouter> = ReturnType<
Expand All @@ -25,7 +27,9 @@ const tRPC_INJECTION_TOKEN = new InjectionToken<unknown>(
export const createTrpcClient = <AppRouter extends AnyRouter>({
url,
options,
batchLinkOptions,
}: TrpcOptions<AppRouter>) => {
const tRPCHeaders = signal<HTTPHeaders>({});
const provideTRPCClient = (): Provider[] => [
provideTrpcCacheState(),
provideTrpcCacheStateStatusManager(),
Expand All @@ -40,6 +44,10 @@ export const createTrpcClient = <AppRouter extends AnyRouter>({
...(options?.links ?? []),
transferStateLink(),
httpBatchLink({
...(batchLinkOptions ?? {}),
headers() {
return tRPCHeaders();
},
url: url ?? '',
}),
],
Expand All @@ -51,5 +59,6 @@ export const createTrpcClient = <AppRouter extends AnyRouter>({
return {
tRPCClient: tRPC_INJECTION_TOKEN as InjectionToken<TrpcClient<AppRouter>>,
provideTRPCClient,
tRPCHeaders,
};
};