Skip to content

Commit 83c7f72

Browse files
feat!: safe query fetching with server api route
1 parent 9b40336 commit 83c7f72

File tree

14 files changed

+366
-112
lines changed

14 files changed

+366
-112
lines changed

playground/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
KIRBY_API_URL=https://kirby-headless-starter.jhnn.dev/api
1+
KIRBY_BASE_URL=https://kirby-headless-starter.jhnn.dev
22
KIRBY_API_TOKEN=test

playground/app.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
<template>
2-
<NuxtPage />
2+
<NuxtLayout>
3+
<NuxtPage />
4+
</NuxtLayout>
35
</template>

playground/layouts/default.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<template>
2+
<header>
3+
<NuxtLink to="/">
4+
Index
5+
</NuxtLink>
6+
/
7+
<NuxtLink to="/client">
8+
<span>Client KQL</span>
9+
</NuxtLink>
10+
</header>
11+
12+
<main>
13+
<slot />
14+
</main>
15+
</template>

playground/nuxt.config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ export default defineNuxtConfig({
77
],
88

99
kql: {
10-
auth: 'bearer',
11-
endpoint: 'kql',
10+
kirbyEndpoint: 'api/kql',
11+
kirbyAuth: 'bearer',
12+
// Disable the following to prevent usage of `usePublicKql()` and `$publicKql()`
13+
clientRequests: true,
1214
},
1315
})

playground/pages/client.vue

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<script setup lang="ts">
2+
const refreshIndex = ref(0)
3+
const query = ref({
4+
query: 'site',
5+
select: {
6+
title: 'site.title',
7+
children: 'site.children',
8+
},
9+
})
10+
11+
const { data, refresh } = await usePublicKql(query)
12+
13+
function updateQuery() {
14+
query.value.select.title = 'site.title.upper'
15+
refresh()
16+
}
17+
</script>
18+
19+
<template>
20+
<div>
21+
<h1>Fetch Data in Client</h1>
22+
<p>
23+
Data is being fetched in the client only. This is faster, since the content is<br>
24+
fetched directly from your Kirby instance. But more unsafe depending on your usecase, because the authorization data is published in the frontend.
25+
</p>
26+
<h2>{{ data?.result?.title }}</h2>
27+
<h3>Query</h3>
28+
<pre>{{ JSON.stringify(query, undefined, 2) }}</pre>
29+
<h3>Response</h3>
30+
<pre>{{ JSON.stringify(data?.result, undefined, 2) }}</pre>
31+
<p>Refreshed: {{ refreshIndex }} times</p>
32+
<button @click="refresh(), refreshIndex++">
33+
Refresh
34+
</button>
35+
<button @click="updateQuery()">
36+
Change query and refresh
37+
</button>
38+
</div>
39+
</template>

playground/pages/index.vue

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,34 @@
11
<script setup lang="ts">
2+
const refreshIndex = ref(0)
23
const query = ref({
34
query: 'site',
45
select: {
56
title: 'site.title',
7+
children: 'site.children',
68
},
79
})
810
911
const { data, refresh } = await useKql(query)
1012
1113
function updateQuery() {
12-
query.value.select = {
13-
title: 'site.title.upper',
14-
}
14+
query.value.select.title = 'site.title.upper'
1515
refresh()
1616
}
1717
</script>
1818

1919
<template>
2020
<div>
21-
<h1>{{ data?.result?.title }}</h1>
21+
<h1>Fetch Data Safely</h1>
22+
<p>Data is being fetched via a custom Nuxt server route for KQL queries.</p>
23+
<h2>{{ data?.result?.title }}</h2>
24+
<h3>Query</h3>
25+
<pre>{{ JSON.stringify(query, undefined, 2) }}</pre>
26+
<h3>Response</h3>
2227
<pre>{{ JSON.stringify(data?.result, undefined, 2) }}</pre>
28+
<p>Refreshed: {{ refreshIndex }} times</p>
29+
<button @click="refresh(), refreshIndex++">
30+
Refresh
31+
</button>
2332
<button @click="updateQuery()">
2433
Change query and refresh
2534
</button>

src/module.ts

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
11
import { fileURLToPath } from 'url'
2+
import { join } from 'pathe'
23
import { defu } from 'defu'
3-
import { createResolver, defineNuxtModule } from '@nuxt/kit'
4+
import { addServerHandler, createResolver, defineNuxtModule } from '@nuxt/kit'
45

56
export interface ModuleOptions {
67
/**
7-
* Kirby API base URL, like `https://kirby.example.com/api`
8-
* @default 'process.env.KIRBY_API_URL'
8+
* Kirby base URL, like `https://kirby.example.com`
9+
* @default 'process.env.KIRBY_BASE_URL'
910
*/
10-
url?: string
11+
kirbyUrl?: string
1112

1213
/**
1314
* Kirby KQL API route path
14-
* @default 'query'
15+
* @default 'api/query'
1516
*/
16-
endpoint?: string
17+
kirbyEndpoint?: string
1718

1819
/**
19-
* Authentication method
20+
* Kirby API authentication method
2021
* Set to `none` to disable authentication
2122
* @default 'basic'
2223
*/
23-
auth?: 'basic' | 'bearer' | 'none'
24+
kirbyAuth?: 'basic' | 'bearer' | 'none'
2425

2526
/**
2627
* Token for bearer authentication
@@ -36,6 +37,16 @@ export interface ModuleOptions {
3637
username: string
3738
password: string
3839
}
40+
41+
/**
42+
* Enable client-side KQL request
43+
* By default, KQL queries are fetched safely for client as well as server via
44+
* an internal server API route
45+
* If enabled, you can use `usePublicKql()` and `$publicKql()` to fetch data
46+
* directly from the Kirby instance
47+
* Note: This means your token or user credentials will be publicly visible
48+
*/
49+
clientRequests?: boolean
3950
}
4051

4152
export default defineNuxtModule<ModuleOptions>({
@@ -47,35 +58,70 @@ export default defineNuxtModule<ModuleOptions>({
4758
},
4859
},
4960
defaults: {
50-
url: process.env.KIRBY_API_URL,
51-
endpoint: 'query',
52-
auth: 'basic',
61+
kirbyUrl: process.env.KIRBY_BASE_URL,
62+
kirbyEndpoint: 'api/query',
63+
kirbyAuth: 'basic',
5364
token: process.env.KIRBY_API_TOKEN,
5465
credentials: {
5566
username: process.env.KIRBY_API_USERNAME,
5667
password: process.env.KIRBY_API_PASSWORD,
5768
},
69+
clientRequests: false,
5870
},
5971
async setup(options, nuxt) {
6072
const { resolve } = createResolver(import.meta.url)
73+
const { kirbyUrl, kirbyEndpoint, kirbyAuth, token, credentials, clientRequests } = options
74+
const apiRoute = '/api/__kql__' as const
6175

62-
// Public runtimeConfig
76+
// Private runtime config
77+
nuxt.options.runtimeConfig.kql = defu(
78+
nuxt.options.runtimeConfig.kql,
79+
{
80+
kirbyUrl,
81+
kirbyEndpoint,
82+
kirbyAuth,
83+
token,
84+
credentials,
85+
clientRequests,
86+
},
87+
)
88+
89+
// Public runtime config
6390
nuxt.options.runtimeConfig.public.kql = defu(
6491
nuxt.options.runtimeConfig.public.kql,
6592
{
66-
url: options.url,
67-
endpoint: options.endpoint,
68-
auth: options.auth,
69-
token: options.token,
70-
credentials: options.credentials,
93+
kirbyUrl,
94+
kirbyEndpoint,
95+
kirbyAuth,
96+
token,
97+
credentials,
98+
clientRequests,
99+
100+
// Only used by the module
101+
apiRoute,
71102
},
72103
)
73104

105+
// Protect authorization data if no public requests are used
106+
if (!clientRequests) {
107+
const { kql } = nuxt.options.runtimeConfig.public
108+
kql.kirbyUrl = ''
109+
kql.kirbyEndpoint = ''
110+
kql.kirbyAuth = ''
111+
kql.token = ''
112+
kql.credentials = { username: '', password: '' }
113+
}
114+
74115
const runtimeDir = fileURLToPath(new URL('./runtime', import.meta.url))
75116
nuxt.options.build.transpile.push(runtimeDir)
76117

77118
nuxt.hook('autoImports:dirs', (dirs) => {
78119
dirs.push(resolve(runtimeDir, 'composables'))
79120
})
121+
122+
addServerHandler({
123+
route: apiRoute,
124+
handler: join(runtimeDir, 'server/api.ts'),
125+
})
80126
},
81127
})

src/runtime/composables/kql.ts

Lines changed: 56 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,67 @@
1-
import type { NitroFetchRequest } from 'nitropack'
2-
import type { Ref } from 'vue'
3-
import { computed, unref } from 'vue'
4-
import type { FetchOptions } from 'ohmyfetch'
5-
import type { KqlQueryRequest, KqlQueryResponse } from '../types'
6-
import { getAuthHeaders, normalizeHeaders } from '../utils/headers'
7-
import type { AsyncData, UseFetchOptions } from '#app'
8-
import { useFetch, useRuntimeConfig } from '#app'
9-
10-
export function useKql<ResT = KqlQueryResponse, ReqT = KqlQueryRequest>(
11-
query: Ref<ReqT> | ReqT | (() => ReqT),
12-
opts: Omit<UseFetchOptions<ResT>, 'baseURL' | 'method' | 'body' > = {},
13-
) {
14-
const { public: { kql: { url, endpoint } } } = useRuntimeConfig()
15-
16-
if (!endpoint)
17-
throw new Error('KQL endpoint is not configured')
18-
19-
const _query = computed(() => {
20-
let q = query
21-
if (typeof q === 'function')
22-
q = (q as (() => ReqT))()
23-
24-
return unref(q)
25-
})
1+
import { hash as ohash } from 'ohash'
2+
import type { KqlPrivateFetchOptions, KqlPublicFetchOptions, KqlQueryRequest, KqlQueryResponse } from '../types'
3+
import { assertKqlPublicConfig, getAuthHeaders, normalizeHeaders } from '../utils'
4+
import type { ModuleOptions } from '../../module'
5+
import { useRuntimeConfig } from '#app'
266

27-
return useFetch<ResT, Error, NitroFetchRequest, ResT>(endpoint, {
28-
...opts,
29-
baseURL: url,
30-
method: 'POST',
31-
body: _query.value,
32-
headers: { ...normalizeHeaders(opts.headers), ...getAuthHeaders() },
33-
}) as AsyncData<ResT, true | Error>
7+
interface InternalState<T> {
8+
promiseMap: Map<string, Promise<T>>
349
}
3510

3611
export function $kql<T = KqlQueryResponse>(
37-
query: T,
38-
opts: Omit<FetchOptions, 'baseURL' | 'method' | 'body' > = {},
39-
) {
40-
const { public: { kql: { url, endpoint } } } = useRuntimeConfig()
12+
query: KqlQueryRequest,
13+
options: KqlPrivateFetchOptions = {},
14+
): Promise<T> {
15+
const { cache = true } = options
16+
const { public: { kql } } = useRuntimeConfig()
17+
18+
const nuxt = useNuxtApp()
19+
const queries: Record<string, T> = nuxt.payload.kqlQueries = (nuxt.payload.kqlQueries || {})
20+
21+
const state = (nuxt.__kql__ || {}) as InternalState<T>
22+
state.promiseMap = state.promiseMap || new Map()
23+
24+
const body = { data: query }
25+
26+
if (!cache) {
27+
return $fetch<T>(kql.apiRoute, {
28+
method: 'POST',
29+
body,
30+
})
31+
}
32+
33+
const key = ohash(query)
34+
35+
if (key in queries)
36+
return Promise.resolve(queries[key])
37+
38+
if (state.promiseMap.has(key))
39+
return state.promiseMap.get(key)
40+
41+
const request = $fetch<T>(kql.apiRoute, { method: 'POST', body })
42+
.then((r) => {
43+
queries[key] = r
44+
state.promiseMap.delete(key)
45+
return r
46+
})
47+
48+
state.promiseMap.set(key, request)
49+
50+
return request
51+
}
4152

42-
if (!endpoint)
43-
throw new Error('KQL endpoint is not configured')
53+
export function $publicKql<T = KqlQueryResponse>(
54+
query: KqlQueryRequest,
55+
opts: KqlPublicFetchOptions = {},
56+
): Promise<T> {
57+
const { public: { kql } } = useRuntimeConfig()
58+
assertKqlPublicConfig(kql as ModuleOptions)
4459

45-
return $fetch<T>(endpoint, {
60+
return $fetch<T>(kql.kirbyEndpoint, {
4661
...opts,
47-
baseURL: url,
62+
baseURL: kql.kirbyUrl,
4863
method: 'POST',
4964
body: query,
50-
headers: { ...normalizeHeaders(opts.headers), ...getAuthHeaders() },
65+
headers: { ...normalizeHeaders(opts.headers), ...getAuthHeaders(kql as ModuleOptions) },
5166
})
5267
}

0 commit comments

Comments
 (0)