Skip to content

Commit 95355f1

Browse files
committed
Inline GDPR client server-side helper to _logger
1 parent 99f3753 commit 95355f1

4 files changed

Lines changed: 133 additions & 129 deletions

File tree

‎_TODO.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,26 @@ dsarVerificationEmails.spec.ts can't run because RESEND_API_KEY isn't set. Provi
1818

1919
5. componentDiscovery.spec.ts expects an error string but receives an object; ensure the helper throws an Error with the message the test asserts.
2020

21+
## Typing client-side API calls and SSR API endpoints
22+
23+
Shared Types vs Swagger / Keeping Docs in Sync
24+
25+
1. Type-only sharing (current approach)
26+
27+
- Pros: zero extra build tooling, server/client stay aligned as long as both import @pages/api/_contracts.
28+
- Cons: no generated docs/SDKs; discipline is required to keep manual docs current.
29+
- How to enforce: treat the contract files as the single source of truth, add lint rules banning request/response literal types outside _contracts, and add lightweight contract tests that instantiate each type against the endpoint handler (failing if fields diverge).
30+
31+
2. Code-first OpenAPI (Zod or TS schemas → OpenAPI)
32+
33+
- Define schemas in Zod/Valibot (or ts-rest) alongside the endpoint. Generate OpenAPI JSON plus TypeScript types from those schemas. Docs (Swagger UI/Redoc) and any client SDKs come from the generated spec, so they’re always in sync.
34+
- Guarantees: CI regenerates the spec and fails when the checked-in artifact is stale; endpoint handlers reuse the same schema for runtime validation, so a mismatch cannot compile.
35+
36+
3. Spec-first OpenAPI + Swagger Codegen
37+
38+
- Maintain an OpenAPI YAML/JSON file as the source of truth, run Swagger Codegen (or openapi-typescript) to produce both server stubs and client SDKs.
39+
- Guarantees: developers edit the spec, run codegen (enforced via a pre-commit/CI task), and the generated server stubs remind you to implement every path/verb. Documentation pages (Swagger UI) are rendered straight from the same spec, so they inherently match the implementation.
40+
2141
## Files with Skipped Tests
2242

2343
social-shares.spec.ts - 12 @wip

‎src/lib/api/GDPR_CLIENT_USAGE.md‎

Lines changed: 42 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,74 @@
1-
# GDPR API Client Usage Guide
1+
# GDPR API Usage Guide
22

3-
This guide shows how to use the type-safe GDPR API client instead of raw fetch calls.
3+
This guide now focuses on sharing contract types between the SSR endpoints and any client or server-side callers. Implementation code should live alongside the endpoint that owns it. Shared files only export types.
44

5-
## 1. Import the client functions
5+
## 1. Import the contract types
66

77
```typescript
8-
import { recordConsent, submitDataRequest, recordConsentServerSide } from '@lib/api/gdpr.client'
8+
import type {
9+
ConsentRequest,
10+
ConsentResponse,
11+
DSARRequestInput,
12+
DSARResponse,
13+
ErrorResponse
14+
} from '@pages/api/_contracts/gdpr.contracts'
915
```
1016

11-
## 2. Client-side usage examples
17+
Using these shared types keeps both sides of the API honest without duplicating runtime logic.
1218

13-
### Recording consent (cookies modal)
19+
## 2. Client-side consent submission example
1420

1521
```typescript
16-
// Before (raw fetch):
17-
await fetch('/api/gdpr/consent', {
18-
method: 'POST',
19-
headers: { 'Content-Type': 'application/json' },
20-
body: JSON.stringify({
21-
DataSubjectId: consentState.DataSubjectId,
22-
purposes,
23-
source: 'cookies_modal',
24-
userAgent: navigator.userAgent,
25-
verified: false,
26-
}),
27-
})
28-
29-
// After (typed client):
30-
const result = await recordConsent({
22+
const payload: ConsentRequest = {
3123
DataSubjectId: consentState.DataSubjectId,
3224
purposes,
3325
source: 'cookies_modal',
3426
userAgent: navigator.userAgent,
3527
verified: false,
36-
})
37-
38-
if (result.success) {
39-
console.log('Consent recorded:', result.data.record.id)
40-
} else {
41-
console.error('Failed:', result.error.message)
4228
}
43-
```
44-
45-
### Submitting data requests (my-data page)
4629

47-
```typescript
48-
// Before (raw fetch):
49-
const response = await fetch('/api/gdpr/request-data', {
30+
const response = await fetch('/api/gdpr/consent', {
5031
method: 'POST',
5132
headers: { 'Content-Type': 'application/json' },
52-
body: JSON.stringify({
53-
email,
54-
requestType: 'ACCESS'
55-
})
33+
body: JSON.stringify(payload),
5634
})
57-
const data = await response.json()
5835

59-
// After (typed client):
60-
const result = await submitDataRequest({
61-
email,
62-
requestType: 'ACCESS'
63-
})
64-
65-
if (result.success) {
66-
accessMessage.textContent = result.data.message
67-
accessMessage.className = 'form-message success'
68-
} else {
69-
accessMessage.textContent = result.error.message
70-
accessMessage.className = 'form-message error'
71-
}
36+
const data: ConsentResponse | ErrorResponse = await response.json()
7237
```
7338

74-
## 3. Server-side usage examples
39+
Because the payload is typed, any contract drift is caught at build time.
40+
41+
## 3. Server-side helper usage
7542

76-
### Contact form API
43+
Server routes can call the shared logger helper:
7744

7845
```typescript
79-
// Before (raw fetch):
80-
const consentResponse = await fetch(`${new URL(request.url).origin}/api/gdpr/consent`, {
81-
method: 'POST',
82-
headers: { 'Content-Type': 'application/json' },
83-
body: JSON.stringify({
84-
DataSubjectId: subjectId,
85-
email: formData.email,
86-
purposes: ['contact'],
87-
source: 'contact_form',
88-
userAgent,
89-
verified: true,
90-
}),
46+
import { recordConsent } from '@pages/api/_logger'
47+
48+
await recordConsent({
49+
origin: new URL(request.url).origin,
50+
DataSubjectId: subjectId,
51+
email: validatedEmail,
52+
purposes: ['contact'],
53+
source: 'contact_form',
54+
userAgent,
55+
verified: true,
9156
})
92-
93-
// After (typed client):
94-
const result = await recordConsentServerSide(
95-
new URL(request.url).origin,
96-
{
97-
DataSubjectId: subjectId,
98-
email: formData.email,
99-
purposes: ['contact'],
100-
source: 'contact_form',
101-
userAgent,
102-
verified: true,
103-
}
104-
)
105-
106-
if (!result.success) {
107-
// Handle error with proper typing
108-
console.error('Consent failed:', result.error.code, result.error.message)
109-
}
11057
```
11158

112-
## 4. Benefits of the typed client
113-
114-
1. **Type Safety**: Compile-time validation of request/response structures
115-
2. **Error Handling**: Consistent error response format across all functions
116-
3. **IntelliSense**: Auto-completion for all fields and response properties
117-
4. **Documentation**: JSDoc comments with usage examples
118-
5. **Centralized**: Single place to manage API call logic and error handling
119-
120-
## 5. Migration checklist
59+
`recordConsent` now encapsulates the fetch logic so API routes stay minimal while still using the shared contracts.
12160

122-
- [ ] `src/components/scripts/store/consent.ts` - Use `recordConsent()`
123-
- [ ] `src/pages/privacy/my-data.astro` - Use `submitDataRequest()`
124-
- [ ] `src/pages/api/contact/index.ts` - Use `recordConsentServerSide()`
125-
- [ ] `src/pages/api/newsletter/index.ts` - Use `recordConsentServerSide()`
61+
## 4. Recommended workflow
12662

127-
## 6. Contract types
63+
1. Define every request/response shape in `@pages/api/_contracts`.
64+
2. Import those types in both the endpoint implementation and any caller (client or server).
65+
3. Keep shared files type-only to avoid duplicated runtime logic.
66+
4. Let each runtime (client, API route, worker) own the fetch/request mechanics that make sense for its environment.
12867

129-
All types are re-exported from `@pages/api/_contracts/gdpr.contracts`:
68+
## 5. Available GDPR types
13069

131-
- `ConsentRequest` - Consent recording payload
132-
- `ConsentResponse` - Consent creation response
133-
- `DSARRequestInput` - Data request payload
134-
- `DSARResponse` - Data request confirmation response
135-
- `ErrorResponse` - Standardized error format
70+
- `ConsentRequest` – Consent recording payload
71+
- `ConsentResponse` – Consent creation response
72+
- `DSARRequestInput` – Data request payload
73+
- `DSARResponse` – Data request confirmation response
74+
- `ErrorResponse` – Standardized error format

‎src/pages/api/_logger/__tests__/index.spec.ts‎

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
/**
22
* Tests for the shared consent logging helper
33
*/
4-
import { describe, it, expect, vi, beforeEach } from 'vitest'
4+
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'
55
import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError'
66
import { recordConsent, type ConsentLogRequest } from '@pages/api/_logger'
77

8-
const recordConsentServerSideMock = vi.hoisted(() => vi.fn())
8+
const fetchMock = vi.hoisted(() => vi.fn())
9+
vi.stubGlobal('fetch', fetchMock)
910

10-
vi.mock('@lib/api/gdpr.client', () => ({
11-
recordConsentServerSide: recordConsentServerSideMock,
12-
}))
11+
const buildFetchResponse = (ok: boolean, payload: unknown) => ({
12+
ok,
13+
json: vi.fn().mockResolvedValue(payload),
14+
}) as unknown as Response
1315

1416
const buildRequest = (): ConsentLogRequest => ({
1517
origin: 'https://example.com',
@@ -24,49 +26,52 @@ const buildRequest = (): ConsentLogRequest => ({
2426
describe('recordConsent helper', () => {
2527
beforeEach(() => {
2628
vi.clearAllMocks()
27-
recordConsentServerSideMock.mockReset()
29+
fetchMock.mockReset()
30+
})
31+
32+
afterAll(() => {
33+
vi.unstubAllGlobals()
2834
})
2935

3036
it('returns consent data when upstream succeeds', async () => {
3137
const mockConsent = { id: 'consent-1' }
32-
recordConsentServerSideMock.mockResolvedValueOnce({
33-
success: true,
34-
data: mockConsent,
35-
})
38+
fetchMock.mockResolvedValueOnce(buildFetchResponse(true, mockConsent))
3639

3740
const response = await recordConsent(buildRequest())
3841

39-
expect(recordConsentServerSideMock).toHaveBeenCalledWith('https://example.com', {
40-
DataSubjectId: '1234',
41-
email: 'user@example.com',
42-
purposes: ['marketing'],
43-
source: 'newsletter_form',
44-
userAgent: 'pytest',
45-
verified: false,
42+
expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/gdpr/consent', {
43+
method: 'POST',
44+
headers: { 'Content-Type': 'application/json' },
45+
body: JSON.stringify({
46+
DataSubjectId: '1234',
47+
email: 'user@example.com',
48+
purposes: ['marketing'],
49+
source: 'newsletter_form',
50+
userAgent: 'pytest',
51+
verified: false,
52+
}),
4653
})
4754
expect(response).toBe(mockConsent)
4855
})
4956

5057
it('propagates upstream error codes and defaults to 502', async () => {
51-
recordConsentServerSideMock.mockResolvedValueOnce({
52-
success: false,
58+
fetchMock.mockResolvedValueOnce(buildFetchResponse(false, {
5359
error: {
5460
code: 'CONSENT_FAILED',
5561
message: 'nope',
5662
},
57-
})
63+
}))
5864

5965
await expect(recordConsent(buildRequest())).rejects.toThrow(ApiFunctionError)
6066
})
6167

6268
it('surfaces rate limit overruns with 429', async () => {
63-
recordConsentServerSideMock.mockResolvedValueOnce({
64-
success: false,
69+
fetchMock.mockResolvedValueOnce(buildFetchResponse(false, {
6570
error: {
6671
code: 'RATE_LIMIT_EXCEEDED',
6772
message: 'too many',
6873
},
69-
})
74+
}))
7075

7176
await expect(recordConsent(buildRequest())).rejects.toMatchObject({
7277
status: 429,

‎src/pages/api/_logger/index.ts‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,57 @@
22
* Shared consent logging helper for server-side API routes
33
* Provides a thin wrapper around the GDPR consent endpoint with origin handling
44
*/
5-
import type { ConsentRequest } from '@pages/api/_contracts/gdpr.contracts'
5+
import type { ConsentRequest, ConsentResponse, ErrorResponse } from '@pages/api/_contracts/gdpr.contracts'
66
import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError'
7-
import { recordConsentServerSide } from '@lib/api/gdpr.client'
87

98
export type ConsentLogRequest = ConsentRequest & {
109
origin: string
1110
}
1211

12+
type ConsentApiResponse = {
13+
success: true
14+
data: ConsentResponse
15+
} | {
16+
success: false
17+
error: ErrorResponse['error']
18+
}
19+
20+
const recordConsentUpstream = async (origin: string, request: ConsentRequest): Promise<ConsentApiResponse> => {
21+
try {
22+
const response = await fetch(`${origin}/api/gdpr/consent`, {
23+
method: 'POST',
24+
headers: { 'Content-Type': 'application/json' },
25+
body: JSON.stringify(request),
26+
})
27+
28+
const data = await response.json()
29+
30+
if (!response.ok) {
31+
const errorResponse = data as ErrorResponse
32+
return {
33+
success: false,
34+
error: errorResponse.error,
35+
}
36+
}
37+
38+
return {
39+
success: true,
40+
data: data as ConsentResponse,
41+
}
42+
} catch (error) {
43+
return {
44+
success: false,
45+
error: {
46+
message: error instanceof Error ? error.message : 'Network error',
47+
code: 'INVALID_REQUEST',
48+
},
49+
}
50+
}
51+
}
52+
1353
export const recordConsent = async (request: ConsentLogRequest) => {
1454
const { origin, ...consentRequest } = request
15-
const response = await recordConsentServerSide(origin, consentRequest)
55+
const response = await recordConsentUpstream(origin, consentRequest)
1656

1757
if (!response.success) {
1858
throw new ApiFunctionError({

0 commit comments

Comments
 (0)