feat(web): типизированный API-клиент, типы DTO, TanStack Query хуки
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
|
import { api } from "./client"
|
||||||
|
import { DEFAULT_PROJECT_ID } from "@/lib/config"
|
||||||
|
|
||||||
|
beforeEach(() => { vi.restoreAllMocks() })
|
||||||
|
|
||||||
|
function mockFetch(body: unknown, ok = true, status = 200) {
|
||||||
|
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
|
ok, status,
|
||||||
|
json: async () => body,
|
||||||
|
text: async () => JSON.stringify(body),
|
||||||
|
} as Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("api client", () => {
|
||||||
|
it("lists accounts at project-scoped path", async () => {
|
||||||
|
const spy = mockFetch([{ id: "a1", provider: "selectel", comment: "" }])
|
||||||
|
const accounts = await api.listAccounts()
|
||||||
|
expect(accounts).toHaveLength(1)
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
`/api/v1/projects/${DEFAULT_PROJECT_ID}/accounts`,
|
||||||
|
expect.objectContaining({ method: "GET" }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sends secret on account creation but path has no secret leakage in response typing", async () => {
|
||||||
|
const spy = mockFetch({ id: "a2", provider: "selectel", comment: "prod" })
|
||||||
|
await api.createAccount({ provider: "selectel", secret: "TOKEN", comment: "prod" })
|
||||||
|
const [, opts] = spy.mock.calls[0]
|
||||||
|
expect((opts as RequestInit).method).toBe("POST")
|
||||||
|
expect(String((opts as RequestInit).body)).toContain("TOKEN")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on non-ok response", async () => {
|
||||||
|
mockFetch({ error: "boom" }, false, 500)
|
||||||
|
await expect(api.listDomains()).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("applies with prune flag", async () => {
|
||||||
|
const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
||||||
|
await api.applyDomain("d1", { applyUpdates: true, applyPrunes: true })
|
||||||
|
const [url, opts] = spy.mock.calls[0]
|
||||||
|
expect(url).toContain("/domains/d1/apply")
|
||||||
|
expect(String((opts as RequestInit).body)).toContain("applyPrunes")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { API_BASE } from "@/lib/config"
|
||||||
|
import type {
|
||||||
|
Account, CreateAccountInput, Template, CreateTemplateInput,
|
||||||
|
Domain, CreateDomainInput, ChangesetResponse, ApplyRequest,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
method: "GET",
|
||||||
|
...init,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `HTTP ${res.status}`
|
||||||
|
try { const b = await res.json(); if (b?.error) msg = b.error } catch { /* ignore */ }
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
listAccounts: () => req<Account[]>("/accounts"),
|
||||||
|
createAccount: (input: CreateAccountInput) =>
|
||||||
|
req<Account>("/accounts", { method: "POST", body: JSON.stringify(input) }),
|
||||||
|
deleteAccount: (id: string) => req<void>(`/accounts/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
listTemplates: () => req<Template[]>("/templates"),
|
||||||
|
createTemplate: (input: CreateTemplateInput) =>
|
||||||
|
req<Template>("/templates", { method: "POST", body: JSON.stringify(input) }),
|
||||||
|
updateTemplate: (id: string, input: CreateTemplateInput) =>
|
||||||
|
req<Template>(`/templates/${id}`, { method: "PUT", body: JSON.stringify(input) }),
|
||||||
|
deleteTemplate: (id: string) => req<void>(`/templates/${id}`, { method: "DELETE" }),
|
||||||
|
|
||||||
|
listDomains: () => req<Domain[]>("/domains"),
|
||||||
|
createDomain: (input: CreateDomainInput) =>
|
||||||
|
req<Domain>("/domains", { method: "POST", body: JSON.stringify(input) }),
|
||||||
|
deleteDomain: (id: string) => req<void>(`/domains/${id}`, { method: "DELETE" }),
|
||||||
|
importZones: (accountId: string) =>
|
||||||
|
req<Domain[]>(`/accounts/${accountId}/import`, { method: "POST" }),
|
||||||
|
setDomainTemplate: (id: string, templateId: string | null) =>
|
||||||
|
req<Domain>(`/domains/${id}`, { method: "PATCH", body: JSON.stringify({ templateId }) }),
|
||||||
|
|
||||||
|
checkDomain: (id: string) => req<ChangesetResponse>(`/domains/${id}/check`),
|
||||||
|
applyDomain: (id: string, body: ApplyRequest) =>
|
||||||
|
req<ChangesetResponse>(`/domains/${id}/apply`, { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export interface Account { id: string; provider: string; comment: string }
|
||||||
|
export interface CreateAccountInput { provider: string; secret: string; comment: string }
|
||||||
|
|
||||||
|
export interface RecordDTO { type: string; name: string; ttl: number; values: string[] }
|
||||||
|
export interface Template { id: string; name: string; records: RecordDTO[]; version: number }
|
||||||
|
export interface CreateTemplateInput { name: string; records: RecordDTO[] }
|
||||||
|
|
||||||
|
export interface Domain {
|
||||||
|
id: string
|
||||||
|
providerAccountId: string
|
||||||
|
zoneName: string
|
||||||
|
zoneId: string
|
||||||
|
templateId: string | null
|
||||||
|
}
|
||||||
|
export interface CreateDomainInput {
|
||||||
|
providerAccountId: string
|
||||||
|
zoneName: string
|
||||||
|
zoneId: string
|
||||||
|
templateId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordView {
|
||||||
|
kind: string // add | update | delete | in_sync
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
desired?: string[]
|
||||||
|
actual?: string[]
|
||||||
|
readOnly: boolean
|
||||||
|
}
|
||||||
|
export interface ChangesetResponse {
|
||||||
|
updates: RecordView[]
|
||||||
|
prunes: RecordView[]
|
||||||
|
readOnly: RecordView[]
|
||||||
|
inSyncCount: number
|
||||||
|
}
|
||||||
|
export interface ApplyRequest { applyUpdates: boolean; applyPrunes: boolean }
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { api } from "@/api/client"
|
||||||
|
import type { CreateAccountInput, CreateTemplateInput, ApplyRequest } from "@/api/types"
|
||||||
|
|
||||||
|
export function useAccounts() {
|
||||||
|
return useQuery({ queryKey: ["accounts"], queryFn: api.listAccounts })
|
||||||
|
}
|
||||||
|
export function useCreateAccount() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateAccountInput) => api.createAccount(input),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useDeleteAccount() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteAccount(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["accounts"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTemplates() {
|
||||||
|
return useQuery({ queryKey: ["templates"], queryFn: api.listTemplates })
|
||||||
|
}
|
||||||
|
export function useCreateTemplate() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CreateTemplateInput) => api.createTemplate(input),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useUpdateTemplate() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, input }: { id: string; input: CreateTemplateInput }) => api.updateTemplate(id, input),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useDeleteTemplate() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteTemplate(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["templates"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDomains() {
|
||||||
|
return useQuery({ queryKey: ["domains"], queryFn: api.listDomains })
|
||||||
|
}
|
||||||
|
export function useImportZones() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (accountId: string) => api.importZones(accountId),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useSetDomainTemplate() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, templateId }: { id: string; templateId: string | null }) => api.setDomainTemplate(id, templateId),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useDeleteDomain() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => api.deleteDomain(id),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["domains"] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
export function useCheckDomain(id: string) {
|
||||||
|
return useQuery({ queryKey: ["check", id], queryFn: () => api.checkDomain(id), enabled: !!id })
|
||||||
|
}
|
||||||
|
export function useApplyDomain(id: string) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: ApplyRequest) => api.applyDomain(id, body),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["check", id] }),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const DEFAULT_PROJECT_ID = "00000000-0000-0000-0000-000000000002"
|
||||||
|
export const API_BASE = `/api/v1/projects/${DEFAULT_PROJECT_ID}`
|
||||||
Reference in New Issue
Block a user