Personal finance tracker
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

openAPI integration with openapi-fetch & openapi-typescript

- tested `/api/me` route

+469 -17
+1
backend/main.go
··· 46 46 api.UseMiddleware(oa.Middleware(api)) 47 47 48 48 // Subete API routes 49 + huma.Get(api, "/me", oa.CurrentUser) 49 50 wallet.New(bundb).Register(api) 50 51 }) 51 52
+36 -7
backend/oauth/oauth.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "errors" 6 7 "fmt" ··· 20 21 21 22 const ( 22 23 // Session key 23 - sessionKey = "appview-session" 24 + sessionKey = "aiueo-session" 24 25 // Session values' keys 25 26 keySessionID = "id" 26 27 keySessionDID = "did" ··· 79 80 func (oa *OAuth) Middleware(api huma.API) func(ctx huma.Context, next func(huma.Context)) { 80 81 return func(ctx huma.Context, next func(huma.Context)) { 81 82 r, _ := humachi.Unwrap(ctx) 82 - if err := oa.resumeSession(r); err != nil { 83 + sess, err := oa.resumeSession(r) 84 + if err != nil { 83 85 huma.WriteErr(api, ctx, http.StatusUnauthorized, "unauthorized", err) 84 86 return 85 87 } 88 + ctx = huma.WithValue(ctx, sessionKey, sess) 86 89 next(ctx) 87 90 } 88 91 } 89 92 93 + type currentUserOutput struct { 94 + Body *UserModel 95 + } 96 + 97 + func (oa *OAuth) CurrentUser(ctx context.Context, input *struct{}) (*currentUserOutput, error) { 98 + user, err := CurrentUser(ctx, oa.db) 99 + if err != nil { 100 + return nil, err 101 + } 102 + resp := &currentUserOutput{} 103 + resp.Body = user 104 + return resp, nil 105 + } 106 + 107 + func CurrentUser(ctx context.Context, db *bun.DB) (*UserModel, error) { 108 + sess, ok := ctx.Value(sessionKey).(*sessionData) 109 + if sess == nil || !ok { 110 + return nil, fmt.Errorf("no current user session") 111 + } 112 + user := &UserModel{} 113 + if err := db.NewSelect().Model(user).Where("account_did = ?", sess.did).Scan(ctx); err != nil { 114 + return nil, err 115 + } 116 + return user, nil 117 + } 118 + 90 119 func (oa *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 91 120 meta := oa.clientApp.Config.ClientMetadata() 92 121 meta.JWKSURI = &oa.jwksURL ··· 170 199 return oa.upsertUser(r.Context(), sessionValue.did, sessionValue.handle) 171 200 } 172 201 173 - func (oa *OAuth) resumeSession(r *http.Request) error { 202 + func (oa *OAuth) resumeSession(r *http.Request) (*sessionData, error) { 174 203 _, sessionValue, err := oa.getSessionValue(r) 175 204 if err != nil { 176 - return err 205 + return nil, err 177 206 } 178 207 179 208 _, err = oa.clientApp.ResumeSession(r.Context(), sessionValue.did, sessionValue.id) 180 209 if err != nil { 181 - return err 210 + return nil, err 182 211 } 183 212 184 - return nil 213 + return sessionValue, nil 185 214 } 186 215 187 216 func (oa *OAuth) deleteSession(w http.ResponseWriter, r *http.Request) error { ··· 217 246 return nil, nil, err 218 247 } 219 248 if session.IsNew { 220 - return nil, nil, fmt.Errorf("no session available for user") 249 + return nil, nil, fmt.Errorf("no current user session") 221 250 } 222 251 223 252 did, err := syntax.ParseDID(session.Values[keySessionDID].(string))
+3 -3
backend/oauth/user.go
··· 11 11 type UserModel struct { 12 12 bun.BaseModel `bun:"table:users"` 13 13 14 - AccountDID syntax.DID `bun:"account_did,pk" json:"account_did"` 14 + AccountDID syntax.DID `bun:"account_did,pk" json:"accountDID"` 15 15 Handle string `bun:"handle,unique,notnull" json:"handle"` 16 - CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` 17 - UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` 16 + CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"createdAt"` 17 + UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updatedAt"` 18 18 } 19 19 20 20 func (oa *OAuth) upsertUser(ctx context.Context, accountDID syntax.DID, handle string) error {
+2
frontend/package.json
··· 23 23 "@types/node": "24.10.9", 24 24 "@vitejs/plugin-vue": "6.0.4", 25 25 "@vue/tsconfig": "0.8.1", 26 + "openapi-fetch": "0.17.0", 27 + "openapi-typescript": "7.13.0", 26 28 "oxfmt": "0.32.0", 27 29 "oxlint": "1.47.0", 28 30 "typescript": "5.9.3",
+4
frontend/src/api/client.ts
··· 1 + import type { paths } from './schema'; 2 + import createClient from 'openapi-fetch'; 3 + 4 + export const openAPIClient = createClient<paths>({ baseUrl: '/api' });
+353
frontend/src/api/schema.d.ts
··· 1 + export interface paths { 2 + '/me': { 3 + parameters: { 4 + query?: never; 5 + header?: never; 6 + path?: never; 7 + cookie?: never; 8 + }; 9 + /** Get me */ 10 + get: operations['get-me']; 11 + put?: never; 12 + post?: never; 13 + delete?: never; 14 + options?: never; 15 + head?: never; 16 + patch?: never; 17 + trace?: never; 18 + }; 19 + '/wallet/{walletId}': { 20 + parameters: { 21 + query?: never; 22 + header?: never; 23 + path?: never; 24 + cookie?: never; 25 + }; 26 + /** Get wallet by wallet ID */ 27 + get: operations['get-wallet-by-wallet-id']; 28 + /** Put wallet by wallet ID */ 29 + put: operations['put-wallet-by-wallet-id']; 30 + post?: never; 31 + /** Delete wallet by wallet ID */ 32 + delete: operations['delete-wallet-by-wallet-id']; 33 + options?: never; 34 + head?: never; 35 + patch?: never; 36 + trace?: never; 37 + }; 38 + '/wallets': { 39 + parameters: { 40 + query?: never; 41 + header?: never; 42 + path?: never; 43 + cookie?: never; 44 + }; 45 + /** List wallets */ 46 + get: operations['list-wallets']; 47 + put?: never; 48 + /** Post wallets */ 49 + post: operations['post-wallets']; 50 + delete?: never; 51 + options?: never; 52 + head?: never; 53 + patch?: never; 54 + trace?: never; 55 + }; 56 + } 57 + export type webhooks = Record<string, never>; 58 + export interface components { 59 + schemas: { 60 + CreateWalletSchema: { 61 + /** 62 + * Format: uri 63 + * @description A URL to the JSON Schema for this object. 64 + * @example /api/schemas/CreateWalletSchema.json 65 + */ 66 + readonly $schema?: string; 67 + name: string; 68 + }; 69 + ErrorDetail: { 70 + /** @description Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id' */ 71 + location?: string; 72 + /** @description Error message text */ 73 + message?: string; 74 + /** @description The value at the given location */ 75 + value?: unknown; 76 + }; 77 + ErrorModel: { 78 + /** 79 + * Format: uri 80 + * @description A URL to the JSON Schema for this object. 81 + * @example /api/schemas/ErrorModel.json 82 + */ 83 + readonly $schema?: string; 84 + /** 85 + * @description A human-readable explanation specific to this occurrence of the problem. 86 + * @example Property foo is required but is missing. 87 + */ 88 + detail?: string; 89 + /** @description Optional list of individual error details */ 90 + errors?: components['schemas']['ErrorDetail'][] | null; 91 + /** 92 + * Format: uri 93 + * @description A URI reference that identifies the specific occurrence of the problem. 94 + * @example https://example.com/error-log/abc123 95 + */ 96 + instance?: string; 97 + /** 98 + * Format: int64 99 + * @description HTTP status code 100 + * @example 400 101 + */ 102 + status?: number; 103 + /** 104 + * @description A short, human-readable summary of the problem type. This value should not change between occurrences of the error. 105 + * @example Bad Request 106 + */ 107 + title?: string; 108 + /** 109 + * Format: uri 110 + * @description A URI reference to human-readable documentation for the error. 111 + * @default about:blank 112 + * @example https://example.com/errors/example 113 + */ 114 + type: string; 115 + }; 116 + UpdateWalletSchema: { 117 + /** 118 + * Format: uri 119 + * @description A URL to the JSON Schema for this object. 120 + * @example /api/schemas/UpdateWalletSchema.json 121 + */ 122 + readonly $schema?: string; 123 + /** Format: int64 */ 124 + id: number; 125 + name: string; 126 + }; 127 + UserModel: { 128 + /** 129 + * Format: uri 130 + * @description A URL to the JSON Schema for this object. 131 + * @example /api/schemas/UserModel.json 132 + */ 133 + readonly $schema?: string; 134 + accountDID: string; 135 + /** Format: date-time */ 136 + createdAt: string; 137 + handle: string; 138 + /** Format: date-time */ 139 + updatedAt: string; 140 + }; 141 + WalletModel: { 142 + /** 143 + * Format: uri 144 + * @description A URL to the JSON Schema for this object. 145 + * @example /api/schemas/WalletModel.json 146 + */ 147 + readonly $schema?: string; 148 + /** Format: date-time */ 149 + createdAt: string; 150 + /** Format: date-time */ 151 + deletedAt: string; 152 + /** Format: int64 */ 153 + id: number; 154 + name: string; 155 + /** Format: date-time */ 156 + updatedAt: string; 157 + }; 158 + }; 159 + responses: never; 160 + parameters: never; 161 + requestBodies: never; 162 + headers: never; 163 + pathItems: never; 164 + } 165 + export type $defs = Record<string, never>; 166 + export interface operations { 167 + 'get-me': { 168 + parameters: { 169 + query?: never; 170 + header?: never; 171 + path?: never; 172 + cookie?: never; 173 + }; 174 + requestBody?: never; 175 + responses: { 176 + /** @description OK */ 177 + 200: { 178 + headers: { 179 + [name: string]: unknown; 180 + }; 181 + content: { 182 + 'application/json': components['schemas']['UserModel']; 183 + }; 184 + }; 185 + /** @description Error */ 186 + default: { 187 + headers: { 188 + [name: string]: unknown; 189 + }; 190 + content: { 191 + 'application/problem+json': components['schemas']['ErrorModel']; 192 + }; 193 + }; 194 + }; 195 + }; 196 + 'get-wallet-by-wallet-id': { 197 + parameters: { 198 + query?: never; 199 + header?: never; 200 + path: { 201 + walletId: number; 202 + }; 203 + cookie?: never; 204 + }; 205 + requestBody?: never; 206 + responses: { 207 + /** @description OK */ 208 + 200: { 209 + headers: { 210 + [name: string]: unknown; 211 + }; 212 + content: { 213 + 'application/json': components['schemas']['WalletModel']; 214 + }; 215 + }; 216 + /** @description Error */ 217 + default: { 218 + headers: { 219 + [name: string]: unknown; 220 + }; 221 + content: { 222 + 'application/problem+json': components['schemas']['ErrorModel']; 223 + }; 224 + }; 225 + }; 226 + }; 227 + 'put-wallet-by-wallet-id': { 228 + parameters: { 229 + query?: never; 230 + header?: never; 231 + path?: never; 232 + cookie?: never; 233 + }; 234 + requestBody: { 235 + content: { 236 + 'application/json': components['schemas']['UpdateWalletSchema']; 237 + }; 238 + }; 239 + responses: { 240 + /** @description OK */ 241 + 200: { 242 + headers: { 243 + [name: string]: unknown; 244 + }; 245 + content: { 246 + 'application/json': components['schemas']['WalletModel']; 247 + }; 248 + }; 249 + /** @description Error */ 250 + default: { 251 + headers: { 252 + [name: string]: unknown; 253 + }; 254 + content: { 255 + 'application/problem+json': components['schemas']['ErrorModel']; 256 + }; 257 + }; 258 + }; 259 + }; 260 + 'delete-wallet-by-wallet-id': { 261 + parameters: { 262 + query?: never; 263 + header?: never; 264 + path: { 265 + walletId: number; 266 + }; 267 + cookie?: never; 268 + }; 269 + requestBody?: never; 270 + responses: { 271 + /** @description OK */ 272 + 200: { 273 + headers: { 274 + [name: string]: unknown; 275 + }; 276 + content: { 277 + 'application/json': string; 278 + }; 279 + }; 280 + /** @description Error */ 281 + default: { 282 + headers: { 283 + [name: string]: unknown; 284 + }; 285 + content: { 286 + 'application/problem+json': components['schemas']['ErrorModel']; 287 + }; 288 + }; 289 + }; 290 + }; 291 + 'list-wallets': { 292 + parameters: { 293 + query?: never; 294 + header?: never; 295 + path?: never; 296 + cookie?: never; 297 + }; 298 + requestBody?: never; 299 + responses: { 300 + /** @description OK */ 301 + 200: { 302 + headers: { 303 + [name: string]: unknown; 304 + }; 305 + content: { 306 + 'application/json': components['schemas']['WalletModel'][] | null; 307 + }; 308 + }; 309 + /** @description Error */ 310 + default: { 311 + headers: { 312 + [name: string]: unknown; 313 + }; 314 + content: { 315 + 'application/problem+json': components['schemas']['ErrorModel']; 316 + }; 317 + }; 318 + }; 319 + }; 320 + 'post-wallets': { 321 + parameters: { 322 + query?: never; 323 + header?: never; 324 + path?: never; 325 + cookie?: never; 326 + }; 327 + requestBody: { 328 + content: { 329 + 'application/json': components['schemas']['CreateWalletSchema']; 330 + }; 331 + }; 332 + responses: { 333 + /** @description OK */ 334 + 200: { 335 + headers: { 336 + [name: string]: unknown; 337 + }; 338 + content: { 339 + 'application/json': components['schemas']['WalletModel']; 340 + }; 341 + }; 342 + /** @description Error */ 343 + default: { 344 + headers: { 345 + [name: string]: unknown; 346 + }; 347 + content: { 348 + 'application/problem+json': components['schemas']['ErrorModel']; 349 + }; 350 + }; 351 + }; 352 + }; 353 + }
+19
frontend/src/api/user.ts
··· 1 + import { queryOptions, useQuery } from '@tanstack/vue-query'; 2 + import { proxyRefs } from 'vue'; 3 + 4 + import { openAPIClient } from './client'; 5 + 6 + export function meQueryOpts() { 7 + return queryOptions({ 8 + queryKey: ['api', 'me'], 9 + queryFn: async () => { 10 + const { data, error } = await openAPIClient.GET('/me'); 11 + if (error) throw error.detail; 12 + return data; 13 + }, 14 + }); 15 + } 16 + 17 + export function useMe() { 18 + return proxyRefs(useQuery(meQueryOpts())); 19 + }
+7
frontend/src/main.ts
··· 3 3 import { createApp } from 'vue'; 4 4 5 5 import { routes } from './+routes.gen'; 6 + import { meQueryOpts } from './api/user'; 6 7 import App from './App.vue'; 7 8 8 9 const app = createApp(App); 9 10 const router = new RutaVue({ routes }); 11 + 12 + router.before(async ({ context }) => { 13 + try { 14 + await context.qc.ensureQueryData(meQueryOpts()); 15 + } catch (error) {} 16 + }); 10 17 11 18 export type Router = typeof router; 12 19
+9 -1
frontend/src/routes/+root-config.ts
··· 1 1 import { createRouteBuilder } from '@jeffydc/ruta-vue'; 2 2 import { QueryClient } from '@tanstack/vue-query'; 3 3 4 + import { meQueryOpts } from '../api/user'; 5 + 4 6 export const route = createRouteBuilder(null, '/', () => ({ 5 7 qc: new QueryClient(), 6 8 })) 7 - .layout() 9 + .layout({ 10 + load: async ({ context }) => { 11 + try { 12 + await context.qc.ensureQueryData(meQueryOpts()); 13 + } catch (error) {} 14 + }, 15 + }) 8 16 .page();
+11 -1
frontend/src/routes/+root-error.vue
··· 1 - <script setup lang="ts"></script> 1 + <script setup lang="ts"> 2 + import { onErrorCaptured, ref } from 'vue'; 3 + 4 + const err = ref(); 5 + 6 + onErrorCaptured((e) => { 7 + err.value = e; 8 + return false; 9 + }); 10 + </script> 2 11 3 12 <template> 13 + {{ err }} 4 14 <slot></slot> 5 15 </template>
+5 -1
frontend/src/routes/+root-page.vue
··· 1 - <script setup lang="ts"></script> 1 + <script setup lang="ts"> 2 + import { useMe } from '../api/user'; 3 + 4 + const me = useMe(); 5 + </script> 2 6 3 7 <template> 4 8 <form action="/oauth/login" method="post">
+19 -4
frontend/vite.config.ts
··· 1 + import fsp from 'node:fs/promises'; 2 + 1 3 import { ruta } from '@jeffydc/ruta-vue/vite'; 2 4 import vue from '@vitejs/plugin-vue'; 3 - import { defineConfig } from 'vite'; 5 + import openAPITS, { astToString } from 'openapi-typescript'; 6 + import { defineConfig, Plugin } from 'vite'; 4 7 import vueDevTools from 'vite-plugin-vue-devtools'; 5 8 6 - const SERVER_HOST = '127.0.0.1' 7 - const BACKEND_TARGET = `http://${SERVER_HOST}:50837` 9 + const SERVER_HOST = '127.0.0.1'; 10 + const BACKEND_TARGET = `http://${SERVER_HOST}:50837`; 8 11 9 12 // https://vite.dev/config/ 10 13 export default defineConfig({ 11 - plugins: [vue(), vueDevTools(), ruta({ routerModule: './src/main.ts' })], 14 + plugins: [vue(), vueDevTools(), ruta({ routerModule: './src/main.ts' }), openAPI()], 12 15 server: { 13 16 host: SERVER_HOST, 14 17 proxy: { ··· 23 26 }, 24 27 }, 25 28 }); 29 + 30 + function openAPI(): Plugin { 31 + const url = `${BACKEND_TARGET}/api/openapi.yaml`; 32 + return { 33 + name: 'openapi', 34 + async buildStart() { 35 + const ast = await openAPITS(url); 36 + const contents = astToString(ast); 37 + fsp.writeFile(new URL('./src/api/schema.d.ts', import.meta.url), contents); 38 + }, 39 + }; 40 + }