Skip to content

Commit 9524923

Browse files
ShaMan123autofix-ci[bot]yusukebe
authored
feat(client): $path (#4636)
* init * tests * fix leading slash * refactor(): reuse mergePath * ci: apply automated fixes * apply suggested patch * support an edge case --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Yusuke Wada <yusuke@kamawada.com>
1 parent 00b405a commit 9524923

4 files changed

Lines changed: 139 additions & 11 deletions

File tree

src/client/client.test.ts

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,38 @@ describe('Basic - $url()', () => {
424424
}).href
425425
).toBe('http://fake/content/search?page=123&limit=20')
426426
})
427+
428+
it.each(['http://fake', 'http://fake/', 'http://fake//', 'http://fake/api'])(
429+
'Should return a correct path via $path() regardless of %s',
430+
async (baseURL) => {
431+
const client = hc<typeof app>(baseURL)
432+
expect(client.index.$path()).toBe('/')
433+
expect(
434+
client.index.$path({
435+
query: {
436+
page: '123',
437+
limit: '20',
438+
},
439+
})
440+
).toBe('/?page=123&limit=20')
441+
expect(client.api.$path()).toBe('/api')
442+
expect(
443+
client.api.posts[':id'].$path({
444+
param: {
445+
id: '123',
446+
},
447+
})
448+
).toBe('/api/posts/123')
449+
expect(
450+
client.content.search.$path({
451+
query: {
452+
page: '123',
453+
limit: '20',
454+
},
455+
})
456+
).toBe('/content/search?page=123&limit=20')
457+
}
458+
)
427459
})
428460

429461
describe('Form - Multiple Values', () => {
@@ -760,6 +792,10 @@ describe('Merge path with `app.route()`', () => {
760792
const url = client.api.bar.$url()
761793
expect(url.href).toBe('http://localhost/api/bar')
762794
})
795+
it('Should work with $path', async () => {
796+
const path = client.api.bar.$path()
797+
expect(path).toBe('/api/bar')
798+
})
763799
})
764800

765801
describe('With a blank path', () => {
@@ -780,6 +816,35 @@ describe('Merge path with `app.route()`', () => {
780816
const url = client.api.v1.me.$url()
781817
expectTypeOf<URL>(url)
782818
expect(url.href).toBe('http://localhost/api/v1/me')
819+
820+
const path = client.api.v1.me.$path()
821+
expectTypeOf<'/api/v1/me'>(path)
822+
expect(path).toBe('/api/v1/me')
823+
})
824+
})
825+
826+
describe('With endpoint pathname', () => {
827+
const app = new Hono().basePath('/api/v1')
828+
const routes = app.route(
829+
'/me',
830+
new Hono().route(
831+
'',
832+
new Hono().get('', async (c) => {
833+
return c.json({ name: 'hono' })
834+
})
835+
)
836+
)
837+
const client = hc<typeof routes>('http://localhost/proxy')
838+
839+
it('Should infer paths correctly', async () => {
840+
// Should not a throw type error
841+
const url = client.api.v1.me.$url()
842+
expectTypeOf<URL>(url)
843+
expect(url.href).toBe('http://localhost/proxy/api/v1/me')
844+
845+
const path = client.api.v1.me.$path()
846+
expectTypeOf<'/api/v1/me'>(path)
847+
expect(path).toBe('/api/v1/me')
783848
})
784849
})
785850
})
@@ -1055,40 +1120,43 @@ describe('Infer the response types from middlewares', () => {
10551120
})
10561121
})
10571122

1058-
describe('$url() with a param option', () => {
1123+
const pathname = <T extends URL | string>(value: T): string =>
1124+
value instanceof URL ? value.pathname : value
1125+
1126+
describe.each(['$path', '$url'] as const)('%s() with a param option', (cmd) => {
10591127
const app = new Hono()
10601128
.get('/posts/:id/comments', (c) => c.json({ ok: true }))
10611129
.get('/something/:firstId/:secondId/:version?', (c) => c.json({ ok: true }))
10621130
type AppType = typeof app
10631131
const client = hc<AppType>('http://localhost')
10641132

1065-
it('Should return the correct path - /posts/123/comments', async () => {
1066-
const url = client.posts[':id'].comments.$url({
1133+
it('Should return the correct url path - /posts/123/comments', async () => {
1134+
const value = client.posts[':id'].comments[cmd]({
10671135
param: {
10681136
id: '123',
10691137
},
10701138
})
1071-
expect(url.pathname).toBe('/posts/123/comments')
1139+
expect(pathname(value)).toBe('/posts/123/comments')
10721140
})
10731141

10741142
it('Should return the correct path - /posts/:id/comments', async () => {
1075-
const url = client.posts[':id'].comments.$url()
1076-
expect(url.pathname).toBe('/posts/:id/comments')
1143+
const value = client.posts[':id'].comments[cmd]()
1144+
expect(pathname(value)).toBe('/posts/:id/comments')
10771145
})
10781146

10791147
it('Should return the correct path - /something/123/456', async () => {
1080-
const url = client.something[':firstId'][':secondId'][':version?'].$url({
1148+
const value = client.something[':firstId'][':secondId'][':version?'][cmd]({
10811149
param: {
10821150
firstId: '123',
10831151
secondId: '456',
10841152
version: undefined,
10851153
},
10861154
})
1087-
expect(url.pathname).toBe('/something/123/456')
1155+
expect(pathname(value)).toBe('/something/123/456')
10881156
})
10891157
})
10901158

1091-
describe('$url() with a query option', () => {
1159+
describe('$url() / $path() with a query option', () => {
10921160
const app = new Hono().get(
10931161
'/posts',
10941162
validator('query', () => {
@@ -1106,6 +1174,13 @@ describe('$url() with a query option', () => {
11061174
},
11071175
})
11081176
expect(url.search).toBe('?filter=test')
1177+
1178+
const path = client.posts.$path({
1179+
query: {
1180+
filter: 'test',
1181+
},
1182+
})
1183+
expect(path).toBe('/posts?filter=test')
11091184
})
11101185
})
11111186

src/client/client.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ export const hc = <T extends Hono<any, any, any>, Prefix extends string = string
168168

169169
const path = parts.join('/')
170170
const url = mergePath(baseUrl, path)
171-
if (method === 'url') {
171+
if (method === 'url' || method === 'path') {
172172
let result = url
173173
if (opts.args[0]) {
174174
if (opts.args[0].param) {
@@ -179,7 +179,10 @@ export const hc = <T extends Hono<any, any, any>, Prefix extends string = string
179179
}
180180
}
181181
result = removeIndexString(result)
182-
return new URL(result)
182+
if (method === 'url') {
183+
return new URL(result)
184+
}
185+
return result.slice(baseUrl.replace(/\/+$/, '').length).replace(/^\/?/, '/')
183186
}
184187
if (method === 'ws') {
185188
const webSocketUrl = replaceUrlProtocol(

src/client/types.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,14 @@ describe('without the leading slash', () => {
3535
it('`foo` should have `$get`', () => {
3636
expectTypeOf(client.foo).toHaveProperty('$get')
3737
expectTypeOf(client.foo.$url()).toEqualTypeOf<TypedURL<'http:', 'localhost', '', '/foo', ''>>()
38+
expectTypeOf(client.foo.$path()).toEqualTypeOf<'/foo'>()
3839
})
3940
it('`foo.bar` should not have `$get`', () => {
4041
expectTypeOf(client.foo.bar).toHaveProperty('$get')
4142
expectTypeOf(client.foo.bar.$url()).toEqualTypeOf<
4243
TypedURL<'http:', 'localhost', '', '/foo/bar', ''>
4344
>()
45+
expectTypeOf(client.foo.bar.$path()).toEqualTypeOf<'/foo/bar'>()
4446
})
4547
it('`foo[":id"].baz` should have `$get`', () => {
4648
expectTypeOf(client.foo[':id'].baz).toHaveProperty('$get')
@@ -58,6 +60,19 @@ describe('without the leading slash', () => {
5860
query: { q: 'hono' },
5961
})
6062
).toEqualTypeOf<TypedURL<'http:', 'localhost', '', '/foo/123/baz', `?${string}`>>()
63+
64+
expectTypeOf(client.foo[':id'].baz.$path()).toEqualTypeOf<'/foo/:id/baz'>()
65+
expectTypeOf(
66+
client.foo[':id'].baz.$path({
67+
param: { id: '123' },
68+
})
69+
).toEqualTypeOf<'/foo/123/baz'>()
70+
expectTypeOf(
71+
client.foo[':id'].baz.$path({
72+
param: { id: '123' },
73+
query: { q: 'hono' },
74+
})
75+
).toEqualTypeOf<`/foo/123/baz?${string}`>()
6176
})
6277
})
6378

@@ -110,3 +125,21 @@ describe('app.all()', () => {
110125
expectTypeOf(res.json()).resolves.toEqualTypeOf<{ msg: string }>()
111126
})
112127
})
128+
129+
describe('with base URL pathname', () => {
130+
const app = new Hono()
131+
.get('foo', (c) => c.json({}))
132+
.get('foo/bar', (c) => c.json({}))
133+
.get('foo/:id/baz', (c) => c.json({}))
134+
const client = hc<typeof app, 'http://localhost/api'>('http://localhost/api')
135+
it('$path', () => {
136+
expectTypeOf(client.foo.$path()).toEqualTypeOf<'/foo'>()
137+
expectTypeOf(client.foo.bar.$path()).toEqualTypeOf<'/foo/bar'>()
138+
expectTypeOf(
139+
client.foo[':id'].baz.$path({
140+
param: { id: '123' },
141+
query: { q: 'hono' },
142+
})
143+
).toEqualTypeOf<`/foo/123/baz?${string}`>()
144+
})
145+
})

src/client/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ export type ClientRequest<Prefix extends string, Path extends string, S extends
9494
>(
9595
arg?: Arg
9696
) => HonoURL<Prefix, Path, Arg>
97+
$path: <
98+
const Arg extends
99+
| (S[keyof S] extends { input: infer R }
100+
? R extends { param: infer P }
101+
? R extends { query: infer Q }
102+
? { param: P; query: Q }
103+
: { param: P }
104+
: R extends { query: infer Q }
105+
? { query: Q }
106+
: {}
107+
: {})
108+
| undefined = undefined,
109+
>(
110+
arg?: Arg
111+
) => BuildPath<Path, Arg>
97112
} & (S['$get'] extends { outputFormat: 'ws' }
98113
? S['$get'] extends { input: infer I }
99114
? {
@@ -146,6 +161,8 @@ type BuildPathname<P extends string, Arg> = Arg extends { param: infer Param }
146161
? `${ApplyParam<TrimStartSlash<P>, Param>}`
147162
: `/${TrimStartSlash<P>}`
148163

164+
type BuildPath<P extends string, Arg> = `${BuildPathname<P, Arg>}${BuildSearch<Arg, 'query'>}`
165+
149166
type BuildTypedURL<
150167
Protocol extends string,
151168
Host extends string,

0 commit comments

Comments
 (0)