Skip to content

Commit c770c83

Browse files
committed
fix(http): hydrate bound requests from original source
1 parent 611fd89 commit c770c83

8 files changed

Lines changed: 191 additions & 17 deletions

File tree

packages/auth/tests/express.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { Request as ArkstackRequest, arkstackHttpPlugin } from '../../http/src'
12
import { afterAll, beforeEach, describe, expect, it } from 'vitest'
23
import { authSecret, cleanupAuthRecords, createAuthToken, createAuthUser, createPersonalAccessToken } from './fixtures/auth'
34

45
import { Auth } from '../src'
56
import { Router as ClearRouter } from 'clear-router/express'
7+
import { Container } from 'clear-router/decorators'
68
import { Hash } from '../../common/src'
79
import { auth } from '../../driver-express/src/middlewares/auth'
810
import express from 'express'
@@ -50,6 +52,62 @@ describe('Express auth integration', () => {
5052
expect(response.body.authToken).toBe(token)
5153
})
5254

55+
it('hydrates the container-bound Arkstack request after authentication', async () => {
56+
const user = await createAuthUser()
57+
const token = await createAuthToken(user.id)
58+
59+
await createPersonalAccessToken(user.id, token)
60+
61+
const app = express()
62+
const router = express.Router()
63+
const Router = createRouter('bound-request')
64+
65+
await import('../../http/src/setup')
66+
await (Router as any).pluginsReady()
67+
68+
expect(Container.has(ArkstackRequest)).toBe(true)
69+
70+
await Router.use(arkstackHttpPlugin)
71+
Router.configure({
72+
container: {
73+
enabled: true,
74+
autoDiscover: true,
75+
},
76+
})
77+
Router.get('/account/request', async (ctx) => {
78+
const req = await Container.resolve(ArkstackRequest, ctx)
79+
80+
return {
81+
authToken: req?.authToken,
82+
authUserFromAuthId: (req?.auth as Auth | undefined)?.user()?.id,
83+
authUserId: req?.authUser?.id,
84+
constructorName: req?.constructor.name,
85+
hasIp: Boolean(req?.ip),
86+
hasSource: Boolean(req?.source),
87+
isArkstackRequest: req instanceof ArkstackRequest,
88+
userId: req?.user?.id,
89+
}
90+
}, [auth])
91+
Router.apply(router)
92+
app.use(router)
93+
94+
const response = await request(app)
95+
.get('/account/request')
96+
.set('Authorization', `Bearer ${token}`)
97+
.expect(200)
98+
99+
expect(response.body).toMatchObject({
100+
authToken: token,
101+
constructorName: 'Request',
102+
hasIp: true,
103+
hasSource: true,
104+
isArkstackRequest: true,
105+
})
106+
expect(String(response.body.authUserFromAuthId)).toBe(String(user.id))
107+
expect(String(response.body.authUserId)).toBe(String(user.id))
108+
expect(String(response.body.userId)).toBe(String(user.id))
109+
})
110+
53111
it('returns an authentication error when the bearer token is missing', async () => {
54112
const app = express()
55113

@@ -89,6 +147,7 @@ describe('Express auth integration', () => {
89147
const personalAccessToken = await auth.login(email, password)
90148

91149
return res.status(200).json({
150+
// @ts-expect-error req.auth is typed from built dependencies
92151
authMatches: req.auth === auth,
93152
authToken: req.authToken,
94153
authUserFromAuthId: req.auth?.user()?.id,
@@ -131,6 +190,7 @@ describe('Express auth integration', () => {
131190
const personalAccessToken = await auth.create(user)
132191

133192
return res.status(201).json({
193+
// @ts-expect-error req.auth is typed from built dependencies
134194
authMatches: req.auth === auth,
135195
authToken: req.authToken,
136196
authUserFromAuthId: req.auth?.user()?.id,
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Model } from 'arkormx'
2+
3+
export abstract class User extends Model {
4+
[key: string]: any
5+
declare email: string
6+
declare name: string
7+
declare password: string
8+
declare createdAt: Date
9+
declare updatedAt: Date
10+
11+
protected static table?: string | undefined = 'users'
12+
}

packages/http/src/Request.ts

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,37 +2,75 @@ import { HeaderMap, RequestOptions, RequestSource } from './types/Http'
22
import { isRecord, normalizeHeaders, unwrapRequestSource } from './helpers'
33

44
import { Request as BaseRequest } from 'clear-router'
5+
import { User } from '@app/models/User'
56

67
/**
78
* Represents an HTTP request, providing a consistent interface for accessing request data.
89
*
910
* @author 3m1n3nc3
1011
*/
11-
export class Request<TUser = unknown> extends BaseRequest {
12+
export class Request<TUser = User> extends BaseRequest {
1213
readonly headers: HeaderMap
1314
readonly ip: string | null
1415
readonly source?: unknown
15-
user?: TUser
16-
auth?: unknown
17-
authUser?: TUser
18-
authToken?: string
16+
private currentUser?: TUser
17+
private currentAuth?: unknown
18+
private currentAuthUser?: TUser
19+
private currentAuthToken?: string
20+
21+
get user (): TUser | undefined {
22+
return this.getSourceRequest()?.user ?? this.currentUser
23+
}
24+
25+
set user (user: TUser | undefined) {
26+
this.currentUser = user
27+
}
28+
29+
get auth (): unknown {
30+
return this.getSourceRequest()?.auth ?? this.currentAuth
31+
}
32+
33+
set auth (auth: unknown) {
34+
this.currentAuth = auth
35+
}
36+
37+
get authUser (): TUser | undefined {
38+
return this.getSourceRequest()?.authUser ?? this.currentAuthUser
39+
}
40+
41+
set authUser (user: TUser | undefined) {
42+
this.currentAuthUser = user
43+
}
44+
45+
get authToken (): string | undefined {
46+
return this.getSourceRequest()?.authToken ?? this.currentAuthToken
47+
}
48+
49+
set authToken (token: string | undefined) {
50+
this.currentAuthToken = token
51+
}
1952

2053
constructor(options: RequestOptions<TUser> = {}) {
2154
super(options)
2255

56+
const source = options.source ?? options.original
57+
const sourceRequest = isRecord(source)
58+
? source as RequestSource<TUser>
59+
: undefined
60+
2361
this.headers = normalizeHeaders(options.headers)
2462
if (this.method)
2563
this.method = options.method!
2664
if (this.url)
2765
this.url = options.url!
2866
if (this.path)
2967
this.path = options.path!
30-
this.ip = options.ip ?? null
31-
this.user = options.user
32-
this.auth = options.auth
33-
this.authUser = options.authUser
34-
this.authToken = options.authToken
35-
this.source = options.source
68+
this.ip = options.ip ?? sourceRequest?.ip ?? null
69+
this.user = options.user ?? sourceRequest?.user
70+
this.auth = options.auth ?? sourceRequest?.auth
71+
this.authUser = options.authUser ?? sourceRequest?.authUser
72+
this.authToken = options.authToken ?? sourceRequest?.authToken
73+
this.source = source
3674

3775
globalThis.request = (key?: string) => key ? this.input(key) : this
3876
}
@@ -60,7 +98,7 @@ export class Request<TUser = unknown> extends BaseRequest {
6098
auth: request.auth,
6199
authUser: request.authUser,
62100
authToken: request.authToken,
63-
source,
101+
source: request,
64102
})
65103
}
66104

@@ -103,6 +141,27 @@ export class Request<TUser = unknown> extends BaseRequest {
103141
return this
104142
}
105143

144+
syncFromSource () {
145+
if (!isRecord(this.source)) {
146+
return this
147+
}
148+
149+
const source = this.source as RequestSource<TUser>
150+
151+
this.user = source.user ?? this.user
152+
this.auth = source.auth ?? this.auth
153+
this.authUser = source.authUser ?? this.authUser
154+
this.authToken = source.authToken ?? this.authToken
155+
156+
return this
157+
}
158+
159+
private getSourceRequest (): RequestSource<TUser> | undefined {
160+
return isRecord(this.source)
161+
? this.source as RequestSource<TUser>
162+
: undefined
163+
}
164+
106165
clearAuthentication () {
107166
this.auth = undefined
108167
this.authUser = undefined

packages/http/src/helpers.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import { isClass } from '@arkstack/common'
66
export const unwrapRequestSource = <TUser> (
77
source: RequestSource<TUser>
88
): RequestSource<TUser> => {
9+
if (source.original) {
10+
return unwrapRequestSource(source.original)
11+
}
12+
913
if (source.headers) {
1014
return source
1115
}
@@ -105,4 +109,4 @@ export const resolveMiddleware = <T extends FunctionMiddleware | MiddlewareClass
105109
}
106110

107111
return middleware as never
108-
}
112+
}

packages/http/src/plugins.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ export const arkstackHttpPlugin = defineClearRouterPlugin<any, ClearHttpContext>
2727
})
2828

2929
bind(Request, ({ request, ctx }: { request: Request, ctx: ClearHttpContext }) => {
30-
return request instanceof Request
30+
const current = request instanceof Request
3131
? request
3232
: Request.from(request ?? ctx)!
33+
34+
return current.syncFromSource()
3335
})
3436

3537
useHttpContext((context) => {

packages/http/src/types/Http.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type RequestSource<TUser = unknown> = {
2121
authToken?: string;
2222
req?: RequestSource<TUser>;
2323
request?: RequestSource<TUser>;
24+
original?: RequestSource<TUser>;
2425
}
2526

2627
export type ResponseSource = {
@@ -45,6 +46,7 @@ export type RequestOptions<TUser = unknown> = {
4546
authUser?: TUser;
4647
authToken?: string;
4748
source?: unknown;
49+
original?: unknown;
4850
}
4951

5052
export interface RequestHelper<TUser = unknown> {

packages/http/tests/index.test.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { Request, Response, arkstackHttpPlugin, normalizeHeaderValue, normalizeHeaders, unwrapRequestSource } from '../src'
2-
import { CoreRouter } from 'clear-router/core'
3-
import { Container } from 'clear-router/decorators'
42
import { describe, expect, it, vi } from 'vitest'
53

4+
import { Container } from 'clear-router/decorators'
5+
import { CoreRouter } from 'clear-router/core'
6+
67
describe('HTTP primitives', () => {
78
it('normalizes request headers and reads bearer tokens consistently', () => {
89
const source = {
@@ -66,13 +67,39 @@ describe('HTTP primitives', () => {
6667
it('binds the Arkstack Request to the current Clear Router request', async () => {
6768
await CoreRouter.use(arkstackHttpPlugin)
6869

69-
const request = new Request({ method: 'POST', path: '/bound' })
70+
const user = { id: 1 }
71+
const auth = {}
72+
const source = {
73+
auth: undefined as object | undefined,
74+
authToken: undefined as string | undefined,
75+
authUser: undefined as typeof user | undefined,
76+
headers: {},
77+
ip: '127.0.0.1',
78+
user: undefined as typeof user | undefined,
79+
}
80+
const request = new Request({
81+
method: 'POST',
82+
original: source,
83+
path: '/bound',
84+
})
85+
86+
source.auth = auth
87+
source.authToken = 'hydrated-token'
88+
source.authUser = user
89+
source.user = user
90+
7091
const resolved = await Container.resolve(Request, {
7192
clearRequest: request,
7293
clearResponse: new Response(),
7394
})
7495

7596
expect(resolved).toBe(request)
97+
expect(resolved?.ip).toBe('127.0.0.1')
98+
expect(resolved?.source).toBe(source)
99+
expect(resolved?.user).toBe(user)
100+
expect(resolved?.auth).toBe(auth)
101+
expect(resolved?.authUser).toBe(user)
102+
expect(resolved?.authToken).toBe('hydrated-token')
76103
})
77104

78105
it('proxies response helpers to the underlying source when available', () => {

packages/http/tsconfig.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"paths": {
5+
"@app/models/*": ["./src/Contracts/*"]
6+
}
7+
}
8+
}

0 commit comments

Comments
 (0)