Skip to content

Commit

Permalink
feat: wip
Browse files Browse the repository at this point in the history
  • Loading branch information
Julien-R44 committed Apr 14, 2024
1 parent e81f155 commit b80fb82
Show file tree
Hide file tree
Showing 8 changed files with 245 additions and 771 deletions.
28 changes: 24 additions & 4 deletions packages/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,29 @@ import { Simplify, Serialize, IsNever, Prettify } from '@tuyau/utils/types'
/**
* Shape of the response returned by Tuyau
*/
export type TuyauResponse<Res> =
| { data: Res; error: null; response: Response; status: number }
| { data: null; error: { message: string }; response: Response; status: number }
export type TuyauResponse<Res extends Record<number, unknown>> =
| {
data: Res[200] extends Simplify<Serialize<infer Response>> ? Response : never
error: null
response: Response
status: number
}
| {
data: null
error: Exclude<keyof Res, 200> extends never
? {
status: unknown
value: unknown
}
: {
[Status in Exclude<keyof Res, 200>]: {
status: Status
value: Res[Status] extends Simplify<Serialize<infer Response>> ? Response : never
}
}[Exclude<keyof Res, 200>]
response: Response
status: number
}

/**
* Shape of the Adonis Client. This is a recursive type that generate
Expand All @@ -16,7 +36,7 @@ export type TuyauResponse<Res> =
*/
export type AdonisClient<in out Route extends Record<string, any>> = {
[K in keyof Route as K extends `:${string}` ? never : K]: Route[K] extends {
response: Simplify<Serialize<infer Res>>
response: infer Res extends Record<number, unknown>
request: infer Request
}
? K extends 'get' | 'head'
Expand Down
74 changes: 66 additions & 8 deletions packages/client/tests/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ test.group('Client', () => {
login: {
post: {
request: { email: string; password: string }
response: Simplify<Serialize<{ token: string }>>
response: {
200: Simplify<Serialize<{ token: string }>>
}
}
}
}
Expand All @@ -37,7 +39,9 @@ test.group('Client', () => {
users: {
get: {
request: { email: string }
response: Simplify<Serialize<{ token: string }>>
response: {
200: Simplify<Serialize<{ token: string }>>
}
}
}
}>('http://localhost:3333')
Expand Down Expand Up @@ -66,7 +70,9 @@ test.group('Client', () => {
users: {
get: {
request: { email?: string }
response: Simplify<Serialize<{ token: string }>>
response: {
200: Simplify<Serialize<{ token: string }>>
}
}
}
}>('http://localhost:3333')
Expand Down Expand Up @@ -94,7 +100,9 @@ test.group('Client', () => {
users: {
post: {
request: { email?: string }
response: Simplify<Serialize<{ token: string }>>
response: {
200: Simplify<Serialize<{ token: string }>>
}
}
}
}>('http://localhost:3333')
Expand All @@ -119,7 +127,9 @@ test.group('Client', () => {
login: {
post: {
request: { email: string; password: string }
response: Simplify<Serialize<{ token: string }>>
response: {
200: Simplify<Serialize<{ token: string }>>
}
}
}
}
Expand All @@ -142,7 +152,9 @@ test.group('Client', () => {
login: {
post: {
request: { email: string; password: string }
response: Simplify<Serialize<{ token: string }>>
response: {
200: Simplify<Serialize<{ token: string }>>
}
}
}
}
Expand Down Expand Up @@ -192,7 +204,9 @@ test.group('Client', () => {
login: {
post: {
request: unknown
response: Simplify<Serialize<{ token: string }>>
response: {
200: Simplify<Serialize<{ token: string }>>
}
}
}
}
Expand Down Expand Up @@ -222,7 +236,9 @@ test.group('Client', () => {
':id': {
get: {
request: { foo: string }
response: Simplify<Serialize<{ id: string }>>
response: {
200: Simplify<Serialize<{ id: string }>>
}
}
}
}
Expand All @@ -233,4 +249,46 @@ test.group('Client', () => {

assert.equal(result.data!.id, '1')
})

test('narrow error', async ({ expectTypeOf }) => {
const tuyau = createTuyau<{
users: {
':id': {
get: {
request: { foo: string }
response: {
200: Simplify<Serialize<{ id: string }>>
404: Simplify<Serialize<{ messageNotFound: string }>>
500: Simplify<Serialize<{ messageServerError: string }>>
}
}
}
}
}>('http://localhost:3333')

nock('http://localhost:3333').get('/users/1?foo=bar').reply(200, { id: '1' })
const result = await tuyau.users({ id: '1' }).get({ query: { foo: 'bar' } })

expectTypeOf(result.data).toEqualTypeOf<{ id: string } | null>()
if (result.error) {
expectTypeOf(result.data).toMatchTypeOf<null>()
expectTypeOf(result.error).toMatchTypeOf<
| { status: 404; value: { messageNotFound: string } }
| { status: 500; value: { messageServerError: string } }
>()

if (result.error.status === 404) {
expectTypeOf(result.error.value).toEqualTypeOf<{ messageNotFound: string }>()
}

if (result.error.status === 500) {
expectTypeOf(result.error.value).toEqualTypeOf<{ messageServerError: string }>()
}
}

if (result.data) {
expectTypeOf(result.data).toEqualTypeOf<{ id: string }>()
expectTypeOf(result.error).toMatchTypeOf<null>()
}
})
})
30 changes: 26 additions & 4 deletions packages/codegen/bin/test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,33 @@
import { assert } from '@japa/assert'
import { configure, processCLIArgs, run } from '@japa/runner'

processCLIArgs(process.argv.splice(2))
import { fileSystem } from '@japa/file-system'
import { expectTypeOf } from '@japa/expect-type'
import { processCLIArgs, configure, run } from '@japa/runner'

/*
|--------------------------------------------------------------------------
| Configure tests
|--------------------------------------------------------------------------
|
| The configure method accepts the configuration to configure the Japa
| tests runner.
|
| The first method call "processCLIArgs" process the command line arguments
| and turns them into a config object. Using this method is not mandatory.
|
| Please consult japa.dev/runner-config for the config docs.
*/
processCLIArgs(process.argv.slice(2))
configure({
files: ['tests/**/*.spec.ts'],
plugins: [assert()],
plugins: [assert(), expectTypeOf(), fileSystem({ autoClean: true })],
})

/*
|--------------------------------------------------------------------------
| Run tests
|--------------------------------------------------------------------------
|
| The following "run" method is required to execute all the tests.
|
*/
run()
4 changes: 3 additions & 1 deletion packages/codegen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@
"devDependencies": {
"@adonisjs/assembler": "^7.2.3",
"@adonisjs/core": "^6.3.1",
"@tuyau/client": "workspace:*",
"@poppinss/matchit": "^3.1.2",
"@tuyau/client": "workspace:*",
"@tuyau/utils": "workspace:*",
"@types/node": "^20.11.25",
"nock": "^14.0.0-beta.5",
"ts-morph": "^22.0.0"
},
"peerDependencies": {
Expand Down
48 changes: 48 additions & 0 deletions packages/codegen/providers/conduit_provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,53 @@
import type { ApplicationService } from '@adonisjs/core/types'

declare module '@adonisjs/core/http' {
interface Response {
continue(): { __status: 100 }
switchingProtocols(etag?: boolean): { __status: 101 }
ok<T>(body: T, etag?: boolean): { __response: T; __status: 200 }
created<T>(body?: T, etag?: boolean): { __response: T; __status: 201 }
accepted<T>(body: T, etag?: boolean): { __response: T; __status: 202 }
nonAuthoritativeInformation<T>(body?: T, etag?: boolean): { __response: T; __status: 203 }
noContent<T>(body?: T, etag?: boolean): { __response: T; __status: 204 }
resetContent<T>(body?: T, etag?: boolean): { __response: T; __status: 205 }
partialContent<T>(body: T, etag?: boolean): { __response: T; __status: 206 }
multipleChoices<T>(body?: T, etag?: boolean): { __response: T; __status: 300 }
movedPermanently<T>(body?: T, etag?: boolean): { __response: T; __status: 301 }
movedTemporarily<T>(body?: T, etag?: boolean): { __response: T; __status: 302 }
seeOther<T>(body?: T, etag?: boolean): { __response: T; __status: 303 }
notModified<T>(body?: T, etag?: boolean): { __response: T; __status: 304 }
useProxy<T>(body?: T, etag?: boolean): { __response: T; __status: 305 }
temporaryRedirect<T>(body?: T, etag?: boolean): { __response: T; __status: 307 }
badRequest<T>(body?: T, etag?: boolean): { __response: T; __status: 400 }
unauthorized<T>(body?: T, etag?: boolean): { __response: T; __status: 401 }
paymentRequired<T>(body?: T, etag?: boolean): { __response: T; __status: 402 }
forbidden<T>(body?: T, etag?: boolean): { __response: T; __status: 403 }
notFound<T>(body?: T, etag?: boolean): { __response: T; __status: 404 }
methodNotAllowed<T>(body?: T, etag?: boolean): { __response: T; __status: 405 }
notAcceptable<T>(body?: T, etag?: boolean): { __response: T; __status: 406 }
proxyAuthenticationRequired<T>(body?: T, etag?: boolean): { __response: T; __status: 407 }
requestTimeout<T>(body?: T, etag?: boolean): { __response: T; __status: 408 }
conflict<T>(body?: T, etag?: boolean): { __response: T; __status: 409 }
gone<T>(body?: T, etag?: boolean): { __response: T; __status: 410 }
lengthRequired<T>(body?: T, etag?: boolean): { __response: T; __status: 411 }
preconditionFailed<T>(body?: T, etag?: boolean): { __response: T; __status: 412 }
requestEntityTooLarge<T>(body?: T, etag?: boolean): { __response: T; __status: 413 }
requestUriTooLong<T>(body?: T, etag?: boolean): { __response: T; __status: 414 }
unsupportedMediaType<T>(body?: T, etag?: boolean): { __response: T; __status: 415 }
requestedRangeNotSatisfiable<T>(body?: T, etag?: boolean): { __response: T; __status: 416 }
expectationFailed<T>(body?: T, etag?: boolean): { __response: T; __status: 417 }
unprocessableEntity<T>(body?: T, etag?: boolean): { __response: T; __status: 422 }
tooManyRequests<T>(body?: T, etag?: boolean): { __response: T; __status: 429 }
internalServerError<T>(body?: T, etag?: boolean): { __response: T; __status: 500 }
notImplemented<T>(body?: T, etag?: boolean): { __response: T; __status: 501 }
badGateway<T>(body?: T, etag?: boolean): { __response: T; __status: 502 }
serviceUnavailable<T>(body?: T, etag?: boolean): { __response: T; __status: 503 }
gatewayTimeout<T>(body?: T, etag?: boolean): { __response: T; __status: 504 }
httpVersionNotSupported<T>(body?: T, etag?: boolean): { __response: T; __status: 505 }
json<T>(body: T, generateEtag?: boolean): { __response: T; __status: 200 }
}
}

export default class TuyauProvider {
constructor(protected app: ApplicationService) {}

Expand Down
63 changes: 60 additions & 3 deletions packages/codegen/tests/example.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,64 @@
import { HttpContext } from '@adonisjs/core/http'
import { test } from '@japa/runner'
import { createTuyau } from '@tuyau/client'
import { Serialize } from '@tuyau/utils/types'
import { Simplify, ConvertReturnTypeToRecordStatusResponse } from '@tuyau/utils/types'
import nock from 'nock'

test.group('Example', () => {
test('add two numbers', ({ assert }) => {
assert.equal(1 + 1, 2)
test.group('Typings', () => {
test('status helpers methods', async ({ expectTypeOf }) => {
function controllerMethod({ response }: HttpContext) {
if (Math.random()) {
return response.badRequest({ messageBadRequest: 'Invalid input' })
} else if (Math.random()) {
return response.badGateway({ messageBadGateway: 'Cannot connect to the upstream server' })
} else if (Math.random()) {
return { messageOkFirst: 'Hello world 2' as const }
} else if (Math.random()) {
return response.json({ messageOk: 'JSON' as const })
}

return response.ok({ messageOk: 'Hello world' as const })
}

const tuyau = createTuyau<{
auth: {
login: {
post: {
request: { email: string; password: string }
response: Simplify<
Serialize<
ConvertReturnTypeToRecordStatusResponse<ReturnType<typeof controllerMethod>>
>
>
}
}
}
}>('http://localhost:3333')

nock('http://localhost:3333').post('/auth/login').reply(200, { token: '123' })

const res = await tuyau.auth.login.post({ email: '[email protected]', password: 'secret' })

if (res.data) {
expectTypeOf(res.data).toEqualTypeOf<
{ messageOk: 'Hello world' } | { messageOkFirst: 'Hello world 2' } | { messageOk: 'JSON' }
>()

if ('messageOk' in res.data) {
expectTypeOf(res.data).toEqualTypeOf<{ messageOk: 'Hello world' } | { messageOk: 'JSON' }>()
}

if ('messageOkFirst' in res.data) {
expectTypeOf(res.data).toEqualTypeOf<{ messageOkFirst: 'Hello world 2' }>()
}
}

if (res.error) {
expectTypeOf(res.error).toEqualTypeOf<
| { status: 400; value: { messageBadRequest: string } }
| { status: 502; value: { messageBadGateway: string } }
>()
}
})
})
8 changes: 8 additions & 0 deletions packages/utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,11 @@ export type IsNever<T> = [T] extends [never] ? true : false
export type Prettify<T> = {
[K in keyof T]: T[K]
} & {}

export type ConvertReturnTypeToRecordStatusResponse<T> = {
[P in T as P extends { __status: infer S extends number } ? S : 200]: P extends {
__response: infer R
}
? R
: P
}
Loading

0 comments on commit b80fb82

Please sign in to comment.