Skip to content

Commit 749159f

Browse files
authored
Refactor account GraphQL operations to typed Apollo Angular GQL classes (#3)
1 parent 7cb881f commit 749159f

16 files changed

Lines changed: 310 additions & 57 deletions

File tree

.github/workflows/frontend.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ jobs:
3434
working-directory: ./frontend
3535
run: npm ci
3636

37+
- name: Generate GraphQL types
38+
working-directory: ./frontend
39+
run: npm run codegen
40+
3741
- name: Check TypeScript compilation
3842
working-directory: ./frontend
3943
run: npx tsc --noEmit

PhantomDave.BankTracking.Api/Services/AccountService.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public AccountService(IUnitOfWork unitOfWork)
2222
{
2323
return await _unitOfWork.Accounts.GetSingleOrDefaultAsync(a => a.Email == email);
2424
}
25-
25+
2626
public async Task<IEnumerable<Account>> GetAllAccountsAsync()
2727
{
2828
return await _unitOfWork.Accounts.GetAllAsync();
@@ -74,11 +74,11 @@ private async Task<bool> IsEmailAlreadyPresent(string email)
7474
public async Task<Account?> LoginAccountAsync(string email, string password)
7575
{
7676
var account = await GetAccountByEmail(email);
77-
if(account == null)
77+
if (account == null)
7878
return null;
7979
return VerifyPassword(password, account.PasswordHash) ? account : null;
8080
}
81-
81+
8282
private static string HashPassword(string password)
8383
{
8484
const int iterations = 100_000;
@@ -90,7 +90,7 @@ private static string HashPassword(string password)
9090

9191
return $"PBKDF2-SHA256${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
9292
}
93-
93+
9494
private static bool VerifyPassword(string password, string stored)
9595
{
9696
if (string.IsNullOrWhiteSpace(stored))

PhantomDave.BankTracking.Api/Types/Mutations/AccountMutations.cs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,50 @@ public async Task<AccountType> CreateAccount(
5353

5454
return AccountType.FromAccount(account);
5555
}
56+
57+
/// <summary>
58+
/// Login to an account
59+
/// </summary>
60+
public async Task<AccountType?> LoginAccount(
61+
string email,
62+
string password,
63+
[Service] AccountService accountService)
64+
{
65+
if (string.IsNullOrWhiteSpace(email))
66+
{
67+
throw new GraphQLException(
68+
ErrorBuilder.New()
69+
.SetMessage("Email is required.")
70+
.SetCode("BAD_USER_INPUT")
71+
.SetExtension("field", "email")
72+
.SetExtension("reason", "required")
73+
.Build());
74+
}
75+
76+
if (string.IsNullOrWhiteSpace(password))
77+
{
78+
throw new GraphQLException(
79+
ErrorBuilder.New()
80+
.SetMessage("Password is required.")
81+
.SetCode("BAD_USER_INPUT")
82+
.SetExtension("field", "password")
83+
.SetExtension("reason", "required")
84+
.Build());
85+
}
86+
87+
var account = await accountService.LoginAccountAsync(email, password);
88+
if (account is null)
89+
{
90+
throw new GraphQLException(
91+
ErrorBuilder.New()
92+
.SetMessage("Invalid email or password.")
93+
.SetCode("AUTHENTICATION_FAILED")
94+
.SetExtension("field", "email")
95+
.SetExtension("reason", "invalid_credentials")
96+
.Build());
97+
}
98+
99+
return AccountType.FromAccount(account);
100+
}
56101
}
57102

PhantomDave.BankTracking.Api/Types/Queries/AccountQueries.cs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,5 @@ public async Task<IEnumerable<AccountType>> GetAccounts(
2929
var account = await accountService.GetAccountByEmail(email);
3030
return account != null ? AccountType.FromAccount(account) : null;
3131
}
32-
33-
public async Task<AccountType?> LoginAccount(
34-
string email,
35-
string password,
36-
[Service] AccountService accountService)
37-
{
38-
var account = await accountService.LoginAccountAsync(email, password);
39-
return account != null ? AccountType.FromAccount(account) : null;
40-
}
4132
}
4233

frontend/GRAPHQL_REFACTORING.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Account GraphQL Operations Refactoring
2+
3+
This document explains the refactoring of account-related GraphQL operations to use Apollo Angular's typed GQL service pattern.
4+
5+
## Overview
6+
7+
All account operations have been migrated from using raw GraphQL queries/mutations with Apollo client to using generated, type-safe Apollo Angular GQL services.
8+
9+
## What Changed
10+
11+
### Before
12+
```typescript
13+
// Direct Apollo usage
14+
this.apollo.query({
15+
query: GET_ACCOUNT_BY_EMAIL,
16+
variables: { email }
17+
})
18+
19+
this.apollo.mutate({
20+
mutation: CREATE_ACCOUNT,
21+
variables: { email, password }
22+
})
23+
```
24+
25+
### After
26+
```typescript
27+
// Generated typed GQL services
28+
this.getAccountByEmailGQL.fetch({ variables: { email } })
29+
this.createAccountGQL.mutate({ variables: { email, password } })
30+
this.loginAccountGQL.mutate({ variables: { email, password } })
31+
```
32+
33+
## Generated GQL Classes
34+
35+
The following injectable GQL classes are now available in `src/generated/graphql.ts`:
36+
37+
### Queries
38+
- **GetAccountByEmailGQL** - Fetch account by email
39+
- **GetAccountsGQL** - Fetch all accounts
40+
41+
### Mutations
42+
- **CreateAccountGQL** - Create a new account
43+
- **LoginAccountGQL** - Login with email and password (changed from Query to Mutation for proper authentication semantics)
44+
45+
## Usage
46+
47+
### Using AccountService (Recommended)
48+
The AccountService provides high-level methods that wrap the GQL classes with error handling and state management:
49+
50+
```typescript
51+
import { AccountService } from './models/account/account-service';
52+
53+
export class MyComponent {
54+
private readonly accountService = inject(AccountService);
55+
56+
async login() {
57+
const account = await this.accountService.loginAccount(email, password);
58+
}
59+
60+
async register() {
61+
const result = await this.accountService.createAccount(email, password);
62+
}
63+
}
64+
```
65+
66+
### Using GQL Classes Directly (Advanced)
67+
For more control, you can inject the GQL classes directly:
68+
69+
```typescript
70+
import { LoginAccountGQL, CreateAccountGQL } from './generated/graphql';
71+
import { firstValueFrom } from 'rxjs';
72+
73+
export class MyComponent {
74+
private readonly loginGQL = inject(LoginAccountGQL);
75+
private readonly createAccountGQL = inject(CreateAccountGQL);
76+
77+
async login(email: string, password: string) {
78+
const result = await firstValueFrom(
79+
this.loginGQL.watch({ variables: { email, password } }).valueChanges
80+
);
81+
return result.data?.loginAccount;
82+
}
83+
84+
async register(email: string, password: string) {
85+
const result = await firstValueFrom(
86+
this.createAccountGQL.mutate({ variables: { email, password } })
87+
);
88+
return result.data?.createAccount;
89+
}
90+
}
91+
```
92+
93+
## Adding New Operations
94+
95+
To add a new account operation:
96+
97+
1. Create a `.graphql` file in `src/graphql/`:
98+
```graphql
99+
# src/graphql/update-account.mutation.graphql
100+
mutation UpdateAccount($id: Int!, $email: String!) {
101+
updateAccount(id: $id, email: $email) {
102+
id
103+
email
104+
createdAt
105+
updatedAt
106+
}
107+
}
108+
```
109+
110+
2. Run code generation:
111+
```bash
112+
npm run codegen
113+
```
114+
115+
3. The new `UpdateAccountGQL` class will be generated and can be injected:
116+
```typescript
117+
import { UpdateAccountGQL } from './generated/graphql';
118+
119+
export class AccountService {
120+
private readonly updateAccountGQL = inject(UpdateAccountGQL);
121+
122+
async updateAccount(id: number, email: string) {
123+
const result = await firstValueFrom(
124+
this.updateAccountGQL.mutate({ variables: { id, email } })
125+
);
126+
return result.data?.updateAccount;
127+
}
128+
}
129+
```
130+
131+
## Benefits
132+
133+
1. **Type Safety**: Full TypeScript type checking for all GraphQL operations
134+
2. **Auto-completion**: IDE provides suggestions for available fields and variables
135+
3. **Consistency**: All operations follow the same pattern
136+
4. **Maintainability**: Easier to add, modify, and remove operations
137+
5. **Code Generation**: No manual type definitions needed
138+
139+
## Migration Notes
140+
141+
- Old `account.queries.ts` and `account.mutations.ts` files have been removed
142+
- The `Account` interface was updated to match generated types (`updatedAt?: string | null`)
143+
- All existing components using AccountService continue to work without changes

frontend/codegen.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { CodegenConfig } from '@graphql-codegen/cli';
22

33
const config: CodegenConfig = {
4-
schema: 'http://localhost:5095/graphql',
4+
schema: './schema.graphql',
55
documents: ['src/**/*.graphql'],
66
generates: {
77
'./src/generated/graphql.ts': {

frontend/schema.graphql

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ scalar DateTime
2525

2626
type Mutation {
2727
createAccount(email: String!, password: String!): AccountType!
28+
loginAccount(email: String!, password: String!): AccountType
2829
}
2930

3031
type Query {
3132
accountByEmail(email: String!): AccountType
3233
accounts: [AccountType!]!
33-
loginAccount(email: String!, password: String!): AccountType
3434
}

frontend/src/app/components/welcome-layout/login-component/login-component.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {MatSelectModule} from '@angular/material/select';
55
import {MatInputModule} from '@angular/material/input';
66
import {MatFormFieldModule} from '@angular/material/form-field';
77
import {FlexComponent} from '../../flex-component/flex-component';
8+
import {AccountService} from '../../../../models/account/account-service';
89

910
@Component({
1011
selector: 'app-login-component',
@@ -22,17 +23,17 @@ import {FlexComponent} from '../../flex-component/flex-component';
2223
})
2324
export class LoginComponent {
2425
private readonly fb = inject(FormBuilder);
26+
private readonly accountService = inject(AccountService);
2527

2628
protected readonly loginForm: FormGroup = this.fb.group({
2729
email: ['', [Validators.required, Validators.email]],
2830
password: ['', [Validators.required, Validators.minLength(6)]]
2931
});
3032

31-
protected onSubmit() {
33+
protected async onSubmit() {
3234
if (this.loginForm.valid) {
3335
const {email, password} = this.loginForm.value;
34-
console.log('Email:', email);
35-
console.log('Password:', password);
36+
await this.accountService.loginAccount(email, password);
3637
} else {
3738
this.loginForm.markAllAsTouched();
3839
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
mutation CreateAccount($email: String!, $password: String!) {
2+
createAccount(email: $email, password: $password) {
3+
id
4+
email
5+
createdAt
6+
updatedAt
7+
}
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
query GetAccountByEmail($email: String!) {
2+
accountByEmail(email: $email) {
3+
id
4+
email
5+
createdAt
6+
updatedAt
7+
}
8+
}

0 commit comments

Comments
 (0)