Skip to content

Commit

Permalink
chore(datasource): Various DataSource component improvements (#2122)
Browse files Browse the repository at this point in the history
1. Calling `refresh` will now no longer fire off a new request if there is already one in-flight (this is shared, so it could be the same URI but across different instances of a DataSource).
2. data URI support. Sometimes its handy to be able to get the characteristics of a DataSource by just passing it some JSON (via a data URI), i.e. for caching, sharing, tests or docs. Just to note this should mostly only be used for testing/docs purposes.

Signed-off-by: John Cowen <john.cowen@konghq.com>
  • Loading branch information
johncowen authored Feb 5, 2024
1 parent e30a96c commit 0479403
Show file tree
Hide file tree
Showing 7 changed files with 116 additions and 91 deletions.
49 changes: 18 additions & 31 deletions src/app/application/components/data-source/DataSource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, test } from 'vitest'

import DataSource from './DataSource.vue'
import { withSources } from '@/../test-support/main'

describe('DataSource', () => {
test("passing an empty uri doesn't fire change", async () => {
Expand All @@ -17,52 +16,40 @@ describe('DataSource', () => {
expect(wrapper.emitted('change')).toBeFalsy()
})
test('cached responses and errors work', async () => {
const err = new Error('error')
const SUCCESS = 'one'
const REFRESHED = 'two'
let res = SUCCESS
withSources(() => {
return {
'/success': (_params: any, source: any) => {
source.close()
return Promise.resolve(res)
},
'/error': (_params: any, source: any) => {
source.close()
throw err
},
}
})

// change
const wrapper = mount(DataSource, {
props: {
src: '/success',
src: 'data:application/json,"one"',
},
})

await flushPromises()
expect(wrapper.emitted('change')?.[0]).toEqual([SUCCESS])
expect(wrapper.emitted('change')?.[0][0]).toEqual('one')

wrapper.setProps({
src: 'data:application/json,"two"',
})

await flushPromises()
expect(wrapper.emitted('change')?.[1][0]).toEqual('two')

// error
wrapper.setProps({
src: '/error',
src: 'data:application/json,"one"',
})

await flushPromises()
expect(wrapper.emitted('error')?.[0]).toEqual([err])
// this time we get two
// the first one comes from the cache
expect(wrapper.emitted('change')?.[2][0]).toEqual('one')
// and this is the fresh uncached call
expect(wrapper.emitted('change')?.[3][0]).toEqual('one')

// refresh the data so we can test we get the previous cached response
// first
res = REFRESHED
wrapper.setProps({
src: '/success',
src: 'data:application/json,try with unparsable json',
})

await flushPromises()
// cached
expect(wrapper.emitted('change')?.[1]).toEqual([SUCCESS])
// refreshed
expect(wrapper.emitted('change')?.[2]).toEqual([REFRESHED])
const error = wrapper.emitted('error')?.[0][0]
expect(error instanceof Error && error.message).toContain('is not valid JSON')
})
})
109 changes: 70 additions & 39 deletions src/app/application/components/data-source/DataSource.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,19 @@
:refresh="refresh"
/>

<span class="visually-hidden" />
<span />
</template>

<script lang="ts" setup>
import { watch, ref, onBeforeUnmount } from 'vue'
import { useDataSourcePool } from '@/utilities'
import { useDataSourcePool } from '@/app/application'
const data = useDataSourcePool()
const props = defineProps({
src: {
type: String,
required: true,
},
})
const props = defineProps<{
src: string
}>()
const message = ref<unknown>(undefined)
const error = ref<Error | undefined>(undefined)
Expand All @@ -30,61 +27,95 @@ const emit = defineEmits<{
(e: 'error', error: Error): void
}>()
type State = {
controller?: AbortController
src?: string
}
let state: State = {}
const sym = Symbol('')
const open = async (src: string) => {
message.value = undefined
state = close(state)
state.src = src
type DataSource = ReturnType<typeof data.source>
type Close = () => void;
let source: DataSource | undefined
let controller = new AbortController()
let close: Close = () => {}
const open = (src: string): Close => {
// abort anything previous and reset the AbortController
// if open if called imediately after a close.
// this could be the controllers second call if it was closed first
if (!controller.signal.aborted) {
controller.abort()
}
controller = new AbortController()
// if src is empty then we have no source and close does nothing
if (src === '') {
return
source = undefined
return () => {}
}
state.controller = new AbortController()
// this should emit proper events
const source = data.source(src, sym)
// get a possibly shared instance of a Source
source = data.source(src, sym)
// add events that will be aborted by the above controler
source.addEventListener(
'message',
(e) => {
message.value = (e as MessageEvent).data
// if we got a message we are no longer erroneous
error.value = undefined
// this should emit proper events
emit('change', message.value)
},
{ signal: state.controller.signal },
{ signal: controller.signal },
)
source.addEventListener(
'error',
(e) => {
error.value = (e as ErrorEvent).error as Error
// this should emit proper events
emit('error', error.value)
},
{ signal: state.controller.signal },
{ signal: controller.signal },
)
}
const close = (state: State) => {
if (typeof state.controller !== 'undefined') {
state.controller.abort()
}
if (typeof state.src !== 'undefined') {
data.close(state.src, sym)
// close
return () => {
// abort anything
controller.abort()
// clear out data
message.value = undefined
// unregister from a possibly shared instance of a Source (i.e. close)
data.close(src, sym)
}
return {}
}
// refresh doesn't need to 'hard close' the Source only reopen (i.e.
// re-request) the shared instance if it has responded already if it is still
// in transit this is essentially a noop
const refresh = () => {
close = open(props.src)
}
// a change of src="" performs a full 'hard close' and acquiring of a new Source
// close is updated to close this new Source
watch(() => props.src, function (src) {
open(src)
close()
close = open(src)
}, { immediate: true })
// close everything
onBeforeUnmount(() => {
state = close(state)
close()
})
const refresh = () => {
open(props.src)
}
</script>
<style lang="scss" scoped>
// 'visually-hidden type' rule applied here so we are dependency free
span {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
</style>
12 changes: 12 additions & 0 deletions src/app/application/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,18 @@ export const services = (app: Record<string, Token>): ServiceDefinition[] => {
$.getDataSourceCacheKeyPrefix,
],
}],
[token('application.datasource.data-uri'), {
service: () => {
return {
'data:application/json,:uri': async ({ uri }: { uri: string }) => {
return JSON.parse(uri)
},
}
},
labels: [
app.sources,
],
}],

]
}
Expand Down
17 changes: 13 additions & 4 deletions src/app/application/services/data-source/Router.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
import { URLPattern } from 'urlpattern-polyfill'
const dataUriProtocol = 'data:'
export default class Router<T> {
routes: Map<URLPattern, T> = new Map()
constructor(routes: Record<string, T>) {
Object.entries(routes).forEach(([key, value]) => {
this.routes.set(new URLPattern({
pathname: key,
}), value)
const pattern = key.startsWith(dataUriProtocol)
? new URLPattern({
protocol: dataUriProtocol,
pathname: key.substring(dataUriProtocol.length),

})
: new URLPattern({
protocol: '*',
pathname: key,
})
this.routes.set(pattern, value)
})
}

match(path: string) {
for (const [pattern, route] of this.routes) {
const _url = `data:${path}`
const _url = path.startsWith('data:') ? path : `source:${path}`
if (pattern.test(_url)) {
const args = pattern.exec(_url)
return {
Expand Down
2 changes: 1 addition & 1 deletion src/app/application/services/data-source/SharedPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ type Entry<T> = {
references: Set<symbol>
}
export default class SharedPool<K, T> {
protected pool: Map<K, Entry<T>> = new Map()
constructor(
protected transition: Transition<K, T>,
protected pool: Map<K, Entry<T>> = new Map(),
) {}

// getter, not init
Expand Down
2 changes: 1 addition & 1 deletion src/utilities/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { TOKENS } from '@/services/tokens'
import { createInjections } from '@/services/utils'
export { useEnv, useI18n, useDataSourcePool } from '@/app/application'
export { useEnv, useI18n } from '@/app/application'
export { useRouter } from '@/app/vue'

export const [
Expand Down
16 changes: 1 addition & 15 deletions test-support/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { beforeEach, afterEach } from 'vitest'

import { services as testing } from './index'
import { TOKENS as $, services as production } from '@/services/production'
import { get, container, build, token } from '@/services/utils'
import { get, container, build } from '@/services/utils'

(async () => {
build(
Expand All @@ -18,17 +18,3 @@ import { get, container, build, token } from '@/services/utils'
beforeEach(() => container.capture?.())
afterEach(() => container.restore?.())
})()

export const withSources = (sources: any) => {
build(
[
[token('sources'), {
service: sources,
arguments: [$.httpClient],
labels: [
$.sources,
],
}],
],
)
}

0 comments on commit 0479403

Please sign in to comment.