Skip to content

Commit 80847b2

Browse files
committed
feat: add sdk
1 parent e1f5606 commit 80847b2

8 files changed

+262
-8
lines changed

src/Sdk.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import ky, { type KyInstance } from 'ky'
2+
import { AuthService } from './services/auth.service'
3+
import { BaseService } from './services/base.service'
4+
import { OpenApiService } from './services/openapi.service'
5+
6+
interface SDKOptions {
7+
baseURL: string
8+
fetch?: typeof fetch
9+
}
10+
11+
export class UndbSDK {
12+
private client: KyInstance
13+
private authService: AuthService
14+
private baseCache: Map<string, BaseService> = new Map()
15+
private openApiService: OpenApiService
16+
17+
constructor(options: SDKOptions) {
18+
const { baseURL, fetch: fetchFn } = options
19+
this.client = ky.create({ prefixUrl: baseURL, credentials: 'include', fetch: fetchFn })
20+
this.authService = new AuthService(this.client)
21+
this.openApiService = new OpenApiService(baseURL, this.authService, fetchFn)
22+
}
23+
24+
get auth() {
25+
return this.authService
26+
}
27+
28+
base(baseName: string): BaseService {
29+
if (!this.baseCache.has(baseName)) {
30+
const baseService = new BaseService(this.client, baseName, this.authService)
31+
this.baseCache.set(baseName, baseService)
32+
}
33+
// biome-ignore lint/style/noNonNullAssertion: Cache is not null
34+
return this.baseCache.get(baseName)!
35+
}
36+
37+
getOpenapiClient<Paths extends {}>() {
38+
return this.openApiService.getClient<Paths>()
39+
}
40+
}

src/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
export { Client, type OpenApiClient } from './Client'
1+
export { type Client as OpenApiClient } from 'openapi-fetch'
2+
export { Client } from './Client'
3+
export { UndbSDK } from './Sdk'

src/services/auth.service.ts

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { KyInstance } from 'ky'
2+
3+
interface AuthResponse {
4+
user: {
5+
id: string
6+
email: string
7+
}
8+
}
9+
10+
export class AuthService {
11+
private client: KyInstance
12+
private token: string | null = null
13+
private isUsingCookie = false
14+
15+
constructor(client: KyInstance) {
16+
this.client = client
17+
}
18+
19+
async loginWithEmail(email: string, password: string): Promise<AuthResponse> {
20+
if (this.token) {
21+
throw new Error('Already authenticated with API token.')
22+
}
23+
const response = await this.client.post('auth/login', { json: { email, password } }).json<AuthResponse>()
24+
this.isUsingCookie = true
25+
return response
26+
}
27+
28+
setToken(token: string) {
29+
if (this.isUsingCookie) {
30+
throw new Error('Already authenticated with email and password.')
31+
}
32+
this.token = token
33+
this.client = this.client.extend({
34+
headers: {
35+
Authorization: `Bearer ${this.token}`,
36+
},
37+
})
38+
}
39+
40+
getToken(): string | null {
41+
return this.token
42+
}
43+
44+
isAuthenticated(): boolean {
45+
return this.token !== null || this.isUsingCookie
46+
}
47+
}

src/services/base.service.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { KyInstance } from 'ky'
2+
import type { AuthService } from './auth.service'
3+
import { TableService } from './table.service'
4+
5+
export class BaseService {
6+
private client: KyInstance
7+
private baseName: string
8+
private authService: AuthService
9+
private tableCache: Map<string, TableService> = new Map()
10+
11+
constructor(client: KyInstance, baseName: string, authService: AuthService) {
12+
this.client = client
13+
this.baseName = baseName
14+
this.authService = authService
15+
}
16+
17+
table(tableName: string, viewName?: string): TableService {
18+
const cacheKey = `${tableName}:${viewName || ''}`
19+
if (!this.tableCache.has(cacheKey)) {
20+
const tableService = new TableService(this.client, this.baseName, tableName, this.authService, viewName)
21+
this.tableCache.set(cacheKey, tableService)
22+
}
23+
// biome-ignore lint/style/noNonNullAssertion: Cache is always set
24+
return this.tableCache.get(cacheKey)!
25+
}
26+
}

src/services/openapi.service.ts

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import createClient, { type Client } from 'openapi-fetch'
2+
import type { AuthService } from './auth.service'
3+
4+
export class OpenApiService {
5+
private authService: AuthService
6+
private baseUrl: string
7+
private fetchFn?: typeof fetch
8+
9+
constructor(baseUrl: string, authService: AuthService, fetchFn?: typeof fetch) {
10+
this.authService = authService
11+
this.baseUrl = baseUrl
12+
this.fetchFn = fetchFn
13+
}
14+
15+
getClient<Paths extends {}>(): Client<Paths> {
16+
const client = createClient<Paths>({
17+
baseUrl: this.baseUrl,
18+
fetch: this.fetchFn,
19+
})
20+
21+
client.use({
22+
onRequest: ({ request }) => {
23+
const token = this.authService.getToken()
24+
if (token) {
25+
request.headers.set('X-Undb-Api-Token', token)
26+
}
27+
},
28+
})
29+
return client
30+
}
31+
}

src/services/table.service.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { KyInstance } from 'ky'
2+
import type { AuthService } from './auth.service'
3+
4+
interface IRecord {
5+
id: string
6+
values: Record<string, unknown>
7+
}
8+
9+
interface CreateRecordPayload {
10+
values: Record<string, unknown>
11+
}
12+
13+
interface UpdateRecordPayload {
14+
values: Record<string, unknown>
15+
}
16+
17+
export class TableService {
18+
private client: KyInstance
19+
private baseName: string
20+
private tableName: string
21+
private viewName?: string
22+
private authService: AuthService
23+
24+
constructor(client: KyInstance, baseName: string, tableName: string, authService: AuthService, viewName?: string) {
25+
this.client = client
26+
this.baseName = baseName
27+
this.tableName = tableName
28+
this.viewName = viewName
29+
this.authService = authService
30+
}
31+
32+
private checkAuth() {
33+
if (!this.authService.isAuthenticated()) {
34+
throw new Error('Authentication is missing. Please login first.')
35+
}
36+
}
37+
38+
private getUrl(path: string): string {
39+
if (this.viewName) {
40+
return `bases/${this.baseName}/tables/${this.tableName}/views/${this.viewName}/${path}`
41+
}
42+
return `bases/${this.baseName}/tables/${this.tableName}/${path}`
43+
}
44+
45+
async getRecords(): Promise<IRecord[]> {
46+
this.checkAuth()
47+
const response = await this.client.get(this.getUrl('records')).json<{ records: IRecord[] }>()
48+
return response.records
49+
}
50+
51+
async createRecord(payload: CreateRecordPayload): Promise<IRecord> {
52+
this.checkAuth()
53+
const response = await this.client.post(this.getUrl('records'), { json: payload }).json<IRecord>()
54+
return response
55+
}
56+
57+
async updateRecord(recordId: string, payload: UpdateRecordPayload): Promise<IRecord> {
58+
this.checkAuth()
59+
const response = await this.client.patch(this.getUrl(`records/${recordId}`), { json: payload }).json<IRecord>()
60+
return response
61+
}
62+
63+
async deleteRecord(recordId: string): Promise<void> {
64+
this.checkAuth()
65+
await this.client.delete(this.getUrl(`records/${recordId}`))
66+
}
67+
}

test/Sdk.test.ts

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { beforeEach, describe, expect, it, jest } from 'bun:test'
2+
import { UndbSDK } from '../src/Sdk'
3+
import { AuthService } from '../src/services/auth.service'
4+
import { BaseService } from '../src/services/base.service'
5+
6+
describe('UndbSDK', () => {
7+
const baseURL = 'http://example.com'
8+
const fetchFn = jest.fn()
9+
10+
let sdk: UndbSDK
11+
12+
beforeEach(() => {
13+
sdk = new UndbSDK({ baseURL, fetch: fetchFn })
14+
})
15+
16+
it('should initialize authService and openApiService', () => {
17+
expect(sdk.auth).toBeInstanceOf(AuthService)
18+
// getOpenapiClient() 返回的不是 OpenApiService 实例,而是一个由 OpenApiService 创建的客户端
19+
expect(sdk.getOpenapiClient()).toBeDefined()
20+
expect(typeof sdk.getOpenapiClient()).toBe('object')
21+
})
22+
23+
it('should return authService instance', () => {
24+
const authService = sdk.auth
25+
expect(authService).toBeInstanceOf(AuthService)
26+
})
27+
28+
it('should return a BaseService instance from base method', () => {
29+
const baseName = 'testBase'
30+
const baseService = sdk.base(baseName)
31+
expect(baseService).toBeInstanceOf(BaseService)
32+
expect(sdk.base(baseName)).toBe(baseService) // should return the same instance from cache
33+
})
34+
35+
it('should return an OpenApi client', () => {
36+
const client = sdk.getOpenapiClient()
37+
expect(client).toBeDefined()
38+
})
39+
})

test/template.test.ts

+9-7
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, test } from 'bun:test'
22
import { http, HttpResponse } from 'msw'
33
import { setupServer } from 'msw/node'
4-
import { Client, OpenApiClient } from '../src/Client'
4+
import type { OpenApiClient } from '../src'
5+
import { UndbSDK } from '../src/Sdk'
56
import type { components, paths } from './templates'
67

78
const SECRET = 'secret'
89
const BASE_URL = 'http://localhost:3721/api'
910

1011
describe('template', () => {
11-
let client: Client
12+
let sdk: UndbSDK
1213
let openapi: OpenApiClient<paths>
1314

1415
let mockFetch: jest.Mock
@@ -19,9 +20,9 @@ describe('template', () => {
1920
headers: new Headers(),
2021
text: async () => '',
2122
}))
22-
client = new Client({ baseUrl: BASE_URL, fetch: mockFetch })
23-
client.setSecret(SECRET)
24-
openapi = client.openapi<paths>()
23+
sdk = new UndbSDK({ baseURL: BASE_URL, fetch: mockFetch })
24+
sdk.auth.setToken(SECRET)
25+
openapi = sdk.getOpenapiClient<paths>()
2526
})
2627

2728
afterEach(() => {
@@ -64,8 +65,9 @@ describe('template', () => {
6465
beforeEach(() => {
6566
// NOTE: server.listen must be called before `createClient` is used to ensure
6667
// the msw can inject its version of `fetch` to intercept the requests.
67-
client = new Client({ baseUrl: BASE_URL })
68-
openapi = client.openapi<paths>()
68+
sdk = new UndbSDK({ baseURL: BASE_URL })
69+
sdk.auth.setToken(SECRET)
70+
openapi = sdk.getOpenapiClient<paths>()
6971
})
7072

7173
afterEach(() => server.resetHandlers())

0 commit comments

Comments
 (0)