Merge pull request #148 from simonredfern/develop

Json schema resource docs
This commit is contained in:
Simon Redfern 2026-01-30 20:28:26 +01:00 committed by GitHub
commit 171aabaecb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1256 additions and 14 deletions

3
.gitignore vendored
View File

@ -62,3 +62,6 @@ test-results/
playwright-report/
playwright-coverage/
shared-constants.js
# Documentation
untracked_docs/

2
components.d.ts vendored
View File

@ -44,7 +44,9 @@ declare module 'vue' {
ElTooltip: typeof import('element-plus/es')['ElTooltip']
GlossarySearchNav: typeof import('./src/components/GlossarySearchNav.vue')['default']
HeaderNav: typeof import('./src/components/HeaderNav.vue')['default']
JsonSchemaViewer: typeof import('./src/components/JsonSchemaViewer.vue')['default']
Menu: typeof import('./src/components/Menu.vue')['default']
MessageDocsJsonSchemaSearchNav: typeof import('./src/components/MessageDocsJsonSchemaSearchNav.vue')['default']
MessageDocsSearchNav: typeof import('./src/components/MessageDocsSearchNav.vue')['default']
Preview: typeof import('./src/components/Preview.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']

View File

@ -36,7 +36,7 @@ import {
HEADER_LINKS_HOVER_COLOR as headerLinksHoverColorSetting,
HEADER_LINKS_BACKGROUND_COLOR as headerLinksBackgroundColorSetting
} from '../obp/style-setting'
import { obpApiActiveVersionsKey, obpGroupedMessageDocsKey, obpMyCollectionsEndpointKey } from '@/obp/keys'
import { obpApiActiveVersionsKey, obpGroupedMessageDocsKey, obpGroupedMessageDocsJsonSchemaKey, obpMyCollectionsEndpointKey } from '@/obp/keys'
import SvelteDropdown from './SvelteDropdown.vue'
const route = useRoute()
@ -51,6 +51,14 @@ const loginUsername = ref('')
const logoffurl = ref('')
const obpApiVersions = ref(inject(obpApiActiveVersionsKey) || [])
const obpMessageDocs = ref(Object.keys(inject(obpGroupedMessageDocsKey) || {}))
const obpMessageDocsJsonSchema = ref(Object.keys(inject(obpGroupedMessageDocsJsonSchemaKey) || {}))
// Combine message docs with JSON Schema items (with "J Schema" postfix)
const combinedMessageDocs = computed(() => {
const regularDocs = obpMessageDocs.value || []
const jsonSchemaDocs = (obpMessageDocsJsonSchema.value || []).map(connector => `${connector} J Schema`)
return [...regularDocs, ...jsonSchemaDocs]
})
// Debug menu items
const debugMenuItems = ref(['/debug/providers-status', '/debug/oidc'])
@ -189,8 +197,8 @@ const setActive = (target: HTMLElement | null) => {
}
}
const handleMore = (command: string) => {
console.log('handleMore called with command:', command)
const handleMore = (command: string, source?: string) => {
console.log('handleMore called with command:', command, 'source:', source)
// Ignore divider
if (command === '---') {
@ -201,11 +209,22 @@ const handleMore = (command: string) => {
if (element !== null) {
element.textContent = command;
}
if (command === '/message-docs') {
// Check if command ends with " J Schema" - if so, it's a JSON Schema message doc
if (command.endsWith(' J Schema')) {
const connector = command.replace(' J Schema', '')
console.log('Navigating to message docs JSON schema:', connector)
router.push({ name: 'message-docs-json-schema', params: { id: connector } })
} else if (command === '/message-docs') {
// Navigate to message docs list
console.log('Navigating to message docs list')
router.push({ name: 'message-docs-list' })
} else if (command === '/message-docs-json-schema') {
// Navigate to message docs JSON schema list
console.log('Navigating to message docs JSON schema list')
router.push({ name: 'message-docs-json-schema-list' })
} else if (command.includes('_')) {
// Regular message docs (connector names contain underscores)
console.log('Navigating to message docs:', command)
router.push({ name: 'message-docs', params: { id: command } })
} else if (command.startsWith('/debug/')) {
@ -292,7 +311,7 @@ const getCurrentPath = () => {
class="menu-right"
id="header-nav-message-docs"
label="Message Docs"
:items="obpMessageDocs"
:items="combinedMessageDocs"
:hover-color="headerLinksHoverColor"
:background-color="headerLinksBackgroundColor"
@select="handleMore"

View File

@ -0,0 +1,328 @@
<!--
- Open Bank Project - API Explorer II
- Copyright (C) 2023-2024, TESOBE GmbH
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
- Email: contact@tesobe.com
- TESOBE GmbH
- Osloerstrasse 16/17
- Berlin 13359, Germany
-
- This product includes software developed at
- TESOBE (http://www.tesobe.com/)
-
-->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { DocumentCopy, Check } from '@element-plus/icons-vue'
interface Props {
schema: any
copyable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
copyable: false
})
const emit = defineEmits<{
refClick: [refId: string]
}>()
const copied = ref(false)
const copyToClipboard = async () => {
try {
const text = JSON.stringify(props.schema, null, 2)
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch (err) {
console.error('Failed to copy: ', err)
}
}
const handleRefClick = (event: Event, refId: string) => {
event.preventDefault()
emit('refClick', refId)
}
// Function to render JSON with clickable $ref links
const renderJsonWithRefs = (obj: any, indent: number = 0): any[] => {
const result: any[] = []
const indentStr = ' '.repeat(indent)
if (obj === null || obj === undefined) {
result.push({ type: 'value', text: String(obj) })
return result
}
if (typeof obj !== 'object') {
const valueClass = typeof obj === 'string' ? 'json-string' :
typeof obj === 'number' ? 'json-number' :
typeof obj === 'boolean' ? 'json-boolean' : 'json-value'
const displayValue = typeof obj === 'string' ? `"${obj}"` : String(obj)
result.push({ type: 'value', text: displayValue, class: valueClass })
return result
}
const isArray = Array.isArray(obj)
const openBracket = isArray ? '[' : '{'
const closeBracket = isArray ? ']' : '}'
result.push({ type: 'bracket', text: openBracket })
const entries = isArray ? obj.map((val, idx) => [idx, val]) : Object.entries(obj)
const totalEntries = entries.length
entries.forEach(([key, value], index) => {
const isLast = index === totalEntries - 1
const newIndent = indent + 1
const newIndentStr = ' '.repeat(newIndent)
result.push({ type: 'newline', text: '\n' })
result.push({ type: 'indent', text: newIndentStr })
// Add key for objects
if (!isArray) {
result.push({ type: 'key', text: `"${key}"`, class: 'json-key' })
result.push({ type: 'separator', text: ': ' })
}
// Check if this is a $ref
if (key === '$ref' && typeof value === 'string') {
// Extract definition name from $ref
const refMatch = value.match(/#\/definitions\/(.+)$/)
if (refMatch) {
const defName = refMatch[1]
result.push({ type: 'ref', text: `"${value}"`, href: `#def-${defName}`, defName })
} else {
result.push({ type: 'value', text: `"${value}"`, class: 'json-string' })
}
} else if (value && typeof value === 'object') {
// Recursively render nested objects/arrays
const nested = renderJsonWithRefs(value, newIndent)
result.push(...nested)
} else {
// Render primitive values
const valueClass = typeof value === 'string' ? 'json-string' :
typeof value === 'number' ? 'json-number' :
typeof value === 'boolean' ? 'json-boolean' :
value === null ? 'json-null' : 'json-value'
const displayValue = typeof value === 'string' ? `"${value}"` : String(value)
result.push({ type: 'value', text: displayValue, class: valueClass })
}
// Add comma if not last
if (!isLast) {
result.push({ type: 'comma', text: ',' })
}
})
if (totalEntries > 0) {
result.push({ type: 'newline', text: '\n' })
result.push({ type: 'indent', text: indentStr })
}
result.push({ type: 'bracket', text: closeBracket })
return result
}
const jsonElements = computed(() => {
return renderJsonWithRefs(props.schema)
})
</script>
<template>
<div class="json-schema-viewer">
<div v-if="copyable" class="schema-header">
<button
@click="copyToClipboard"
class="copy-button"
:class="{ 'copied': copied }"
>
<span v-if="!copied">
<el-icon :size="10">
<DocumentCopy />
</el-icon>
Copy
</span>
<span v-else>
<el-icon :size="10">
<Check />
</el-icon>
Copied!
</span>
</button>
</div>
<div class="schema-container">
<pre class="schema-pre"><code class="schema-code"><template v-for="(element, index) in jsonElements" :key="index"><span
v-if="element.type === 'ref'"
class="json-ref"
:title="`Jump to definition: ${element.defName}`"
><a :href="element.href" class="ref-link" @click="handleRefClick($event, element.href)">{{ element.text }}</a></span><span
v-else-if="element.type === 'key' || element.type === 'value'"
:class="element.class"
>{{ element.text }}</span><span v-else>{{ element.text }}</span></template></code></pre>
</div>
</div>
</template>
<style scoped>
.json-schema-viewer {
margin: 1rem 0;
border-radius: 8px;
overflow: hidden;
background: #1e1e1e;
border: 1px solid #333;
position: relative;
}
.schema-header {
background: #2d2d2d;
padding: 0.5rem 1rem;
border-bottom: 1px solid #333;
display: flex;
justify-content: flex-end;
}
.copy-button {
background: #444;
border: 1px solid #666;
color: #ddd;
padding: 0.25rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.25rem;
}
.copy-button:hover {
background: #555;
border-color: #777;
}
.copy-button.copied {
background: #4caf50;
border-color: #4caf50;
color: white;
}
.schema-container {
max-height: 500px;
overflow-y: auto;
}
.schema-pre {
margin: 0;
padding: 1.5rem;
background: #1e1e1e;
color: #ddd;
font-family: 'Fira Code', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
overflow-x: auto;
}
.schema-code {
background: transparent;
padding: 0;
border-radius: 0;
font-family: inherit;
font-size: inherit;
white-space: pre;
}
/* JSON Syntax Highlighting */
.json-key {
color: #e06c75;
font-weight: 500;
}
.json-string {
color: #98c379;
}
.json-number {
color: #d19a66;
}
.json-boolean {
color: #56b6c2;
}
.json-null {
color: #c678dd;
}
.json-ref {
position: relative;
}
.ref-link {
color: #61afef;
text-decoration: underline;
text-decoration-style: dotted;
cursor: pointer;
transition: all 0.2s ease;
}
.ref-link:hover {
color: #84c5ff;
text-decoration-style: solid;
background-color: rgba(97, 175, 239, 0.1);
}
/* Custom scrollbar */
.schema-container::-webkit-scrollbar {
width: 8px;
}
.schema-container::-webkit-scrollbar-track {
background: #2d2d2d;
}
.schema-container::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.schema-container::-webkit-scrollbar-thumb:hover {
background: #777;
}
.schema-pre::-webkit-scrollbar {
height: 8px;
}
.schema-pre::-webkit-scrollbar-track {
background: #2d2d2d;
}
.schema-pre::-webkit-scrollbar-thumb {
background: #555;
border-radius: 4px;
}
.schema-pre::-webkit-scrollbar-thumb:hover {
background: #777;
}
</style>

View File

@ -0,0 +1,183 @@
<!--
- Open Bank Project - API Explorer II
- Copyright (C) 2023-2024, TESOBE GmbH
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
- Email: contact@tesobe.com
- TESOBE GmbH
- Osloerstrasse 16/17
- Berlin 13359, Germany
-
- This product includes software developed at
- TESOBE (http://www.tesobe.com/)
-
-->
<script setup lang="ts">
import { reactive, ref, onBeforeMount, inject, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { useRoute } from 'vue-router'
import { SEARCH_LINKS_COLOR as searchLinksColorSetting } from '../obp/style-setting'
import { connectors } from '../obp/message-docs'
import { obpGroupedMessageDocsJsonSchemaKey } from '@/obp/keys'
let connector = connectors[0]
const route = useRoute()
const groupedMessageDocsJsonSchema = ref(inject(obpGroupedMessageDocsJsonSchemaKey) || {})
const docs = ref({})
const groups = ref({})
const sortedKeys = ref([])
const activeKeys = ref([])
const messageDocKeys = ref([])
const searchLinksColor = ref(searchLinksColorSetting)
const form = reactive({
search: ''
})
onBeforeMount(() => {
setDocs()
})
watch(
() => route.params.id,
async (id) => {
setDocs()
}
)
const isKeyFound = (keys, item) => keys.every((k) => item.toLowerCase().includes(k))
const filterKeys = (keys, key) => {
const splitKey = key.split(' ').map((k) => k.toLowerCase())
return keys.filter((title) => {
const isGroupFound = isKeyFound(splitKey, title)
const items = docs.value[title].filter((item) => isGroupFound || isKeyFound(splitKey, item))
groups.value[title] = items
return isGroupFound || items.length > 0
})
}
const searchEvent = (value) => {
if (value) {
messageDocKeys.value = filterKeys(activeKeys.value, value)
} else {
groups.value = JSON.parse(JSON.stringify(docs.value))
messageDocKeys.value = Object.keys(groups.value)
}
}
const setDocs = () => {
const paramConnector = route.params.id
if (connectors.includes(paramConnector)) {
connector = paramConnector
}
const messageDocsJsonSchemaData = groupedMessageDocsJsonSchema.value[connector]
const messageDocsJsonSchema = messageDocsJsonSchemaData?.grouped || messageDocsJsonSchemaData || {}
docs.value = Object.keys(messageDocsJsonSchema).reduce((doc, key) => {
doc[key] = messageDocsJsonSchema[key].map((group) => group.method_name)
return doc
}, {})
groups.value = JSON.parse(JSON.stringify(docs.value))
messageDocKeys.value = Object.keys(groups.value)
activeKeys.value = Object.keys(groups.value)
}
</script>
<template>
<el-row>
<el-col :span="24">
<el-input
v-model="form.search"
placeholder="Search"
:prefix-icon="Search"
@input="searchEvent"
/>
</el-col>
</el-row>
<el-collapse v-model="activeKeys">
<el-collapse-item v-for="key in messageDocKeys" :title="key" :key="key" :name="key">
<div class="el-tabs--right">
<div v-for="(value, key) of groups[key]" :key="value" class="message-docs-router-tab">
<a class="message-docs-router-link" :id="`${value}-quick-nav`" v-bind:href="`#${value}`">
{{ value }}
</a>
</div>
</div>
</el-collapse-item>
</el-collapse>
</template>
<style scoped>
.api-router-link {
width: 100%;
margin-left: 15px;
font-family: 'Roboto';
text-decoration: none;
color: #39455f;
display: inline-block;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
}
.api-router-tab {
border-left: 2px solid var(--el-menu-border-color);
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.api-router-tab:hover,
.active-api-router-tab {
border-left: 2px solid v-bind(searchLinksColor);
}
.api-router-tab:hover .api-router-link,
.active-api-router-link {
color: v-bind(searchLinksColor);
}
.message-docs-router-link {
margin-left: 15px;
font-size: 13px;
font-family: 'Roboto';
text-decoration: none;
color: #39455f;
display: inline-block;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
}
.message-docs-router-tab {
border-left: 2px solid var(--el-menu-border-color);
line-height: 30px;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
}
.message-docs-router-tab:hover,
.active-message-docs-router-tab {
border-left: 2px solid v-bind(searchLinksColor);
}
.message-docs-router-tab:hover .message-docs-router-link {
color: v-bind(searchLinksColor);
}
</style>

View File

@ -38,7 +38,12 @@ import { createI18n } from 'vue-i18n'
import { languages, defaultLocale } from './language'
import { cache as cacheResourceDocs, cacheDoc as cacheResourceDocsDoc } from './obp/resource-docs'
import { cache as cacheMessageDocs, cacheDoc as cacheMessageDocsDoc } from './obp/message-docs'
import {
cache as cacheMessageDocs,
cacheDoc as cacheMessageDocsDoc,
cacheJsonSchema as cacheMessageDocsJsonSchema,
cacheDocJsonSchema as cacheMessageDocsJsonSchemaDoc
} from './obp/message-docs'
import { OBP_API_VERSION, getMyAPICollections, getMyAPICollectionsEndpoint } from './obp'
import { getOBPGlossary } from './obp/glossary'
@ -47,16 +52,18 @@ import './assets/main.css'
import '@fontsource/roboto/300.css'
import '@fontsource/roboto/400.css'
import '@fontsource/roboto/700.css'
import { getCacheStorageInfo } from './obp/common-functions'
import {
obpApiActiveVersionsKey,
obpApiHostKey,
obpGlossaryKey,
obpGroupedMessageDocsKey,
obpGroupedMessageDocsJsonSchemaKey,
obpGroupedResourceDocsKey,
obpMyCollectionsEndpointKey,
obpResourceDocsKey
} from './obp/keys'
import { getCacheStorageInfo } from './obp/common-functions'
;(async () => {
const app = createApp(App)
const router = await appRouter()
@ -272,6 +279,13 @@ async function setupData(app: App<Element>, worker: Worker) {
const cacheStorageOfMessageDocs = await caches.open('obp-message-docs-cache') // Please note: The global 'caches' read-only property returns the 'CacheStorage' object associated with the current context.
// 'match': Checks if a given Request is a key in any of the Cache objects that the CacheStorage object tracks, and returns a Promise that resolves to that match.
const cachedResponseOfMessageDocs = await cacheStorageOfMessageDocs.match('/')
// 'open': Returns a Promise that resolves to the Cache object matching the cacheName(obp-message-docs-json-schema-cache) (a new cache is created if it doesn't already exist.)
const cacheStorageOfMessageDocsJsonSchema = await caches.open(
'obp-message-docs-json-schema-cache'
) // Please note: The global 'caches' read-only property returns the 'CacheStorage' object associated with the current context.
// 'match': Checks if a given Request is a key in any of the Cache objects that the CacheStorage object tracks, and returns a Promise that resolves to that match.
const cachedResponseOfMessageDocsJsonSchema =
await cacheStorageOfMessageDocsJsonSchema.match('/')
// Listen to Web worker
worker.onmessage = async (event) => {
@ -286,6 +300,10 @@ async function setupData(app: App<Element>, worker: Worker) {
await cacheMessageDocsDoc(cacheStorageOfMessageDocs)
console.log('Message Docs cache was updated.')
}
if (event.data === 'update-message-docs-json-schema') {
await cacheMessageDocsJsonSchemaDoc(cacheStorageOfMessageDocsJsonSchema)
console.log('Message Docs JSON Schema cache was updated.')
}
}
const { resourceDocs, groupedDocs } = await cacheResourceDocs(
@ -298,6 +316,11 @@ async function setupData(app: App<Element>, worker: Worker) {
cachedResponseOfMessageDocs,
worker
)
const messageDocsJsonSchema = await cacheMessageDocsJsonSchema(
cacheStorageOfMessageDocsJsonSchema,
cachedResponseOfMessageDocsJsonSchema,
worker
)
// Provide data to a component's descendants
// App-level provides are available to all components rendered in the app
@ -306,6 +329,7 @@ async function setupData(app: App<Element>, worker: Worker) {
app.provide(obpApiActiveVersionsKey, Object.keys(resourceDocs).sort())
app.provide(obpGroupedResourceDocsKey, groupedDocs)
app.provide(obpGroupedMessageDocsKey, messageDocs)
app.provide(obpGroupedMessageDocsJsonSchemaKey, messageDocsJsonSchema)
app.provide(obpApiHostKey, import.meta.env.VITE_OBP_API_HOST)
const glossary = await getOBPGlossary()
app.provide(obpGlossaryKey, glossary)

View File

@ -31,6 +31,9 @@ export const obpResourceDocsKey = Symbol('OBP-ResourceDocs') as InjectionKey<any
export const obpApiActiveVersionsKey = Symbol('OBP-APIActiveVersions') as InjectionKey<any>
export const obpGroupedResourceDocsKey = Symbol('OBP-GroupedResourceDocs') as InjectionKey<any>
export const obpGroupedMessageDocsKey = Symbol('OBP-GroupedMessageDocs') as InjectionKey<any> // This cause an issue
export const obpGroupedMessageDocsJsonSchemaKey = Symbol(
'OBP-GroupedMessageDocsJsonSchema'
) as InjectionKey<any>
export const obpApiHostKey = Symbol('OBP-API-Host') as InjectionKey<any>
export const obpGlossaryKey = Symbol('OBP-Glossary') as InjectionKey<any>
export const obpMyCollectionsEndpointKey = Symbol('OBP-MyCollectionsEndpoint') as InjectionKey<any>
export const obpMyCollectionsEndpointKey = Symbol('OBP-MyCollectionsEndpoint') as InjectionKey<any>

View File

@ -43,7 +43,15 @@ export async function getOBPMessageDocs(item: string): Promise<any> {
return await get(`obp/${OBP_API_VERSION}/message-docs/${item}`)
}
export function getGroupedMessageDocs(docs: any): Promise<any> {
// Get Message Docs JSON Schema
export async function getOBPMessageDocsJsonSchema(item: string): Promise<any> {
const logMessage = `Loading message docs JSON schema { connector: ${item} }`
console.log(logMessage)
updateLoadingInfoMessage(logMessage)
return await get(`obp/v6.0.0/message-docs/${item}/json-schema`)
}
export function getGroupedMessageDocs(docs: any): any {
return docs.message_docs.reduce((values: any, doc: any) => {
const tag = doc.adapter_implementation.group.replace('-', '').trim()
;(values[tag] = values[tag] || []).push(doc)
@ -51,6 +59,77 @@ export function getGroupedMessageDocs(docs: any): Promise<any> {
}, {})
}
export function getGroupedMessageDocsJsonSchema(docs: any): any {
console.log('getGroupedMessageDocsJsonSchema - Raw docs:', docs)
// Access messages from the correct path: properties.messages.items
const messages = docs.properties?.messages?.items
const definitions = docs.definitions || {}
if (!messages || !Array.isArray(messages)) {
console.log('No messages array found, falling back to definitions')
// Fallback to old structure if messages array doesn't exist
if (!definitions || typeof definitions !== 'object') {
console.log('No definitions object found either')
return { grouped: {}, definitions: {} }
}
// Convert definitions object to array format and group by InBound/OutBound prefix
const grouped: any = {}
Object.keys(definitions).forEach((methodName: string) => {
const schema = definitions[methodName]
// Determine category based on method name prefix
let category = 'Uncategorized'
if (methodName.startsWith('InBound')) {
category = 'Inbound Methods'
} else if (methodName.startsWith('OutBound')) {
category = 'Outbound Methods'
}
if (!grouped[category]) {
grouped[category] = []
}
grouped[category].push({
method_name: methodName,
category: category,
outbound_schema: schema,
inbound_schema: schema
})
})
console.log('Grouped definitions result:', grouped)
return { grouped, definitions }
}
// Group messages by adapter_implementation.group
console.log('Processing messages array')
const grouped: any = {}
messages.forEach((message: any) => {
const category =
message.adapter_implementation?.group?.replace('-', '').trim() || 'Uncategorized'
if (!grouped[category]) {
grouped[category] = []
}
// Keep original schemas with $refs intact
grouped[category].push({
method_name: message.process,
category: category,
description: message.description,
outbound_schema: message.outbound_schema,
inbound_schema: message.inbound_schema,
message_format: message.message_format
})
})
console.log('Grouped messages result:', grouped)
console.log('Definitions:', definitions)
return { grouped, definitions }
}
export async function cacheDoc(cacheStorageOfMessageDocs: any): Promise<any> {
const messageDocs = await connectors.reduce(async (agroup: any, connector: any) => {
const logMessage = `Caching message docs { connector: ${connector} }`
@ -71,11 +150,30 @@ async function getCacheDoc(cacheStorageOfMessageDocs: any): Promise<any> {
return await cacheDoc(cacheStorageOfMessageDocs)
}
export async function cache(
cacheStorage: any,
cachedResponse: any,
worker: any
): Promise<any> {
export async function cacheDocJsonSchema(cacheStorageOfMessageDocsJsonSchema: any): Promise<any> {
const messageDocsJsonSchema = await connectors.reduce(async (agroup: any, connector: any) => {
const logMessage = `Caching message docs JSON schema { connector: ${connector} }`
console.log(logMessage)
updateLoadingInfoMessage(logMessage)
const group = await agroup
const docs = await getOBPMessageDocsJsonSchema(connector)
if (!Object.keys(docs).includes('code')) {
group[connector] = getGroupedMessageDocsJsonSchema(docs)
}
return group
}, Promise.resolve({}))
await cacheStorageOfMessageDocsJsonSchema.put(
'/',
new Response(JSON.stringify(messageDocsJsonSchema))
)
return messageDocsJsonSchema
}
async function getCacheDocJsonSchema(cacheStorageOfMessageDocsJsonSchema: any): Promise<any> {
return await cacheDocJsonSchema(cacheStorageOfMessageDocsJsonSchema)
}
export async function cache(cacheStorage: any, cachedResponse: any, worker: any): Promise<any> {
try {
worker.postMessage('update-message-docs')
return await cachedResponse.json()
@ -87,3 +185,20 @@ export async function cache(
return await getCacheDoc(cacheStorage)
}
}
export async function cacheJsonSchema(
cacheStorage: any,
cachedResponse: any,
worker: any
): Promise<any> {
try {
worker.postMessage('update-message-docs-json-schema')
return await cachedResponse.json()
} catch (error) {
console.warn('No message docs JSON schema cache or malformed cache.')
console.log('Caching message docs JSON schema...')
const isServerActive = await isServerUp()
if (!isServerActive) throw new Error('API Server is not responding.')
return await getCacheDocJsonSchema(cacheStorage)
}
}

View File

@ -30,6 +30,8 @@ import GlossaryView from '../views/GlossaryView.vue'
import HelpView from '../views/HelpView.vue'
import MessageDocsView from '../views/MessageDocsView.vue'
import MessageDocsListView from '../views/MessageDocsListView.vue'
import MessageDocsJsonSchemaView from '../views/MessageDocsJsonSchemaView.vue'
import MessageDocsJsonSchemaListView from '../views/MessageDocsJsonSchemaListView.vue'
import BodyView from '../views/BodyView.vue'
import Content from '../components/Content.vue'
import Preview from '../components/Preview.vue'
@ -86,6 +88,16 @@ export default async function router(): Promise<any> {
name: 'message-docs',
component: isServerActive ? MessageDocsView : InternalServerErrorView
},
{
path: '/message-docs-json-schema',
name: 'message-docs-json-schema-list',
component: isServerActive ? MessageDocsJsonSchemaListView : InternalServerErrorView
},
{
path: '/message-docs-json-schema/:id',
name: 'message-docs-json-schema',
component: isServerActive ? MessageDocsJsonSchemaView : InternalServerErrorView
},
{
path: '/resource-docs',
redirect: () => {

View File

@ -0,0 +1,114 @@
<!--
- Open Bank Project - API Explorer II
- Copyright (C) 2023-2024, TESOBE GmbH
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
- Email: contact@tesobe.com
- TESOBE GmbH
- Osloerstrasse 16/17
- Berlin 13359, Germany
-
- This product includes software developed at
- TESOBE (http://www.tesobe.com/)
-
-->
<script setup lang="ts">
import { ref, inject, computed } from 'vue'
import { useRouter } from 'vue-router'
import { obpGroupedMessageDocsJsonSchemaKey } from '@/obp/keys'
const router = useRouter()
const groupedMessageDocsJsonSchema = ref(inject(obpGroupedMessageDocsJsonSchemaKey) || {})
const connectorList = computed(() => {
return Object.keys(groupedMessageDocsJsonSchema.value || {}).sort()
})
function navigateToConnector(connectorId: string) {
router.push(`/message-docs-json-schema/${connectorId}`)
}
</script>
<template>
<el-container class="message-docs-list-container">
<el-main>
<h1>Message Documentation - JSON Schema</h1>
<p class="subtitle">Browse connector message documentation with JSON Schema definitions</p>
<div class="message-docs-list">
<div v-if="connectorList.length === 0" class="empty-message">
No JSON schema message documentation available
</div>
<div v-else>
<a
v-for="connector in connectorList"
:key="connector"
@click="navigateToConnector(connector)"
class="message-doc-link"
>
{{ connector }}
</a>
</div>
</div>
</el-main>
</el-container>
</template>
<style scoped>
.message-docs-list-container {
min-height: calc(100vh - 60px);
padding: 2rem;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: #303133;
margin-bottom: 0.5rem;
}
.subtitle {
font-size: 0.9rem;
color: #909399;
margin-bottom: 1.5rem;
}
.message-docs-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.message-doc-link {
padding: 12px 16px;
color: #409eff;
text-decoration: none;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
display: block;
}
.message-doc-link:hover {
background-color: #ecf5ff;
color: #337ecc;
}
.empty-message {
color: #909399;
font-style: italic;
}
</style>

View File

@ -0,0 +1,439 @@
<!--
- Open Bank Project - API Explorer II
- Copyright (C) 2023-2024, TESOBE GmbH
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
- Email: contact@tesobe.com
- TESOBE GmbH
- Osloerstrasse 16/17
- Berlin 13359, Germany
-
- This product includes software developed at
- TESOBE (http://www.tesobe.com/)
-
-->
<script setup lang="ts">
import { ref, onBeforeMount, inject, watch, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import SearchNav from '../components/MessageDocsJsonSchemaSearchNav.vue'
import { connectors } from '../obp/message-docs'
import { obpGroupedMessageDocsJsonSchemaKey } from '@/obp/keys'
import JsonSchemaViewer from '../components/JsonSchemaViewer.vue'
let connector = connectors[0]
const route = useRoute()
const groupedMessageDocsJsonSchema = ref(inject(obpGroupedMessageDocsJsonSchemaKey) || {})
const messageDocsJsonSchema = ref(null as any)
const definitions = ref(null as any)
const definitionsPanelScrollbar = ref(null as any)
onBeforeMount(() => {
setDoc()
})
watch(
() => route.params.id,
async (id) => {
setDoc()
}
)
const setDoc = () => {
const paramConnector = route.params.id
if (connectors.includes(paramConnector)) {
connector = paramConnector
}
const data = groupedMessageDocsJsonSchema.value[connector]
// Handle both old and new data structures
if (data?.grouped) {
messageDocsJsonSchema.value = data.grouped
definitions.value = data.definitions
} else {
messageDocsJsonSchema.value = data
definitions.value = null
}
}
function hasSchema(value: any) {
return value && (value.outbound_schema || value.inbound_schema)
}
// Handle $ref link clicks from JsonSchemaViewer components
function handleRefClick(href: string) {
console.log('handleRefClick called with href:', href)
if (!href) {
console.log('No href provided')
return
}
// Extract the definition ID from the href (e.g., #def-BasicGeneralContext)
const targetId = href.substring(1) // Remove the #
console.log('Target ID:', targetId)
const targetElement = document.getElementById(targetId)
console.log('Target element:', targetElement)
console.log('Definitions panel scrollbar:', definitionsPanelScrollbar.value)
if (targetElement && definitionsPanelScrollbar.value) {
// Get the scrollbar's wrap element
const scrollWrap = definitionsPanelScrollbar.value.$refs.wrap as HTMLElement
console.log('Scroll wrap:', scrollWrap)
if (scrollWrap) {
// Calculate the position of the target element relative to the scrollable container
const containerTop = scrollWrap.getBoundingClientRect().top
const targetTop = targetElement.getBoundingClientRect().top
const currentScroll = scrollWrap.scrollTop
const offset = targetTop - containerTop + currentScroll - 20 // 20px padding from top
console.log('Scrolling to offset:', offset)
// Smooth scroll to the target
scrollWrap.scrollTo({
top: offset,
behavior: 'smooth'
})
// Add a highlight effect
targetElement.classList.add('highlight-definition')
setTimeout(() => {
targetElement.classList.remove('highlight-definition')
}, 2000)
} else {
console.log('No scroll wrap found')
}
} else {
console.log('Target element or scrollbar not found')
}
}
</script>
<template>
<el-container class="message-docs-container">
<el-aside class="search-nav" width="18%">
<el-scrollbar>
<SearchNav />
</el-scrollbar>
</el-aside>
<el-main class="message-docs-content">
<el-scrollbar>
<el-backtop :right="100" :bottom="100" />
<div class="message-docs-header">
<h1>{{ connector }}</h1>
<p class="connector-subtitle">Message Docs - JSON Schema</p>
<p class="version-indicator">v1.2.4 - Debug Click Events</p>
</div>
<div v-for="(group, key) of messageDocsJsonSchema" :key="key">
<div v-for="(value, key) of group" :key="value">
<el-divider></el-divider>
<a v-bind:href="`#${value.method_name}`" :id="value.method_name">
<h2>{{ value.method_name }}</h2>
</a>
<p v-if="value.description">{{ value.description }}</p>
<section class="topics">
<div>
<strong>Category: </strong>
<el-tag type="info" round>{{ value.category || 'Uncategorized' }}</el-tag>
</div>
<div v-if="value.message_format">
<strong>Message Format: </strong>
<el-tag type="success" round>{{ value.message_format }}</el-tag>
</div>
</section>
<section v-if="value.outbound_schema">
<h3>Outbound Schema</h3>
<JsonSchemaViewer :schema="value.outbound_schema" copyable @refClick="handleRefClick" />
</section>
<section v-if="value.inbound_schema">
<h3>Inbound Schema</h3>
<JsonSchemaViewer :schema="value.inbound_schema" copyable @refClick="handleRefClick" />
</section>
<section v-if="!hasSchema(value)">
<p class="no-schema-message">No schema information available for this method.</p>
</section>
</div>
</div>
</el-scrollbar>
</el-main>
<el-aside class="definitions-panel" width="28%">
<el-scrollbar ref="definitionsPanelScrollbar">
<div class="definitions-panel-content">
<div class="definitions-header">
<h2>Schema Definitions</h2>
<p class="definitions-subtitle">
Reference schemas used in messages
</p>
</div>
<!-- Debug info -->
<div v-if="false">
{{ console.log('Definitions:', definitions) }}
{{ console.log('Definitions keys:', definitions ? Object.keys(definitions) : 'null') }}
{{ console.log('Message docs:', messageDocsJsonSchema) }}
</div>
<div v-if="definitions && Object.keys(definitions).length > 0">
<div v-for="(defSchema, defName) in definitions" :key="defName" class="definition-item">
<a v-bind:href="`#def-${defName}`" :id="`def-${defName}`">
<h3>{{ defName }}</h3>
</a>
<JsonSchemaViewer :schema="defSchema" copyable @refClick="handleRefClick" />
</div>
</div>
<div v-else class="no-definitions">
<p>No schema definitions available</p>
<p style="font-size: 0.8rem; margin-top: 10px;">
Debug: definitions = {{ definitions ? 'exists' : 'null' }},
keys = {{ definitions ? Object.keys(definitions).length : 0 }}
</p>
</div>
</div>
</el-scrollbar>
</el-aside>
</el-container>
</template>
<style scoped>
.message-docs-container {
height: calc(100vh - 60px);
}
/* Left Sidebar - Search Navigation */
.search-nav {
border-right: 1px solid #e4e7ed;
}
.search-nav :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.search-nav :deep(.el-scrollbar__view) {
padding: 10px;
}
/* Main Content Area */
.message-docs-content {
color: #39455f;
font-family: 'Roboto';
padding: 0;
}
.message-docs-content :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.message-docs-content :deep(.el-scrollbar__view) {
padding: 25px 30px;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%;
box-sizing: border-box;
}
/* Right Sidebar - Definitions Panel */
.definitions-panel {
border-left: 2px solid #e4e7ed;
background-color: #f9fafb;
}
.definitions-panel :deep(.el-scrollbar__wrap) {
overflow-x: hidden;
}
.definitions-panel :deep(.el-scrollbar__view) {
padding: 0;
}
.definitions-panel-content {
padding: 20px;
}
.definitions-header {
position: sticky;
top: 0;
background-color: #f9fafb;
padding: 10px 0 20px 0;
margin-bottom: 10px;
border-bottom: 2px solid #e4e7ed;
z-index: 10;
}
.definitions-header h2 {
color: #303133;
margin: 0 0 8px 0;
font-size: 1.3rem;
font-family: 'Roboto';
font-weight: 600;
}
.definitions-subtitle {
color: #909399;
margin: 0;
font-size: 0.85rem;
font-family: 'Roboto';
}
h2 {
word-wrap: break-word;
overflow-wrap: break-word;
}
section {
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%;
margin-bottom: 20px;
}
pre {
font-family: 'Roboto';
max-width: 100%;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
a {
text-decoration: none;
color: #39455f;
word-wrap: break-word;
overflow-wrap: break-word;
}
div {
font-size: 14px;
}
.content :deep(strong) {
font-family: 'Roboto';
}
.content :deep(a):hover {
background-color: #39455f;
}
.message-docs-header {
padding: 20px 0;
border-bottom: 2px solid #e4e7ed;
margin-bottom: 20px;
}
.message-docs-header h1 {
font-size: 1.75rem;
font-weight: 600;
color: #303133;
margin: 0 0 0.5rem 0;
word-wrap: break-word;
overflow-wrap: break-word;
}
.connector-subtitle {
font-size: 1rem;
color: #909399;
margin: 0;
}
.version-indicator {
font-size: 0.75rem;
color: #67c23a;
margin: 0.25rem 0 0 0;
font-weight: 600;
}
.topics {
display: flex;
flex-direction: column;
gap: 10px;
margin: 15px 0;
}
.topics > div {
display: flex;
align-items: center;
gap: 10px;
}
.no-schema-message {
color: #909399;
font-style: italic;
padding: 15px;
background-color: #f5f7fa;
border-radius: 4px;
}
.definition-item {
margin-bottom: 25px;
padding: 15px;
background-color: #ffffff;
border-radius: 6px;
border: 1px solid #e4e7ed;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.definition-item:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-color: #409eff;
}
.definition-item h3 {
color: #409eff;
margin-top: 0;
margin-bottom: 12px;
font-size: 1rem;
word-wrap: break-word;
overflow-wrap: break-word;
font-weight: 600;
font-family: 'Roboto';
}
.definition-item a {
text-decoration: none;
}
.definition-item a:hover h3 {
text-decoration: underline;
}
.no-definitions {
text-align: center;
padding: 40px 20px;
color: #909399;
font-style: italic;
font-family: 'Roboto';
}
/* Highlight animation for scrolled-to definitions */
@keyframes highlight-pulse {
0% {
background-color: rgba(64, 158, 255, 0.15);
}
50% {
background-color: rgba(64, 158, 255, 0.25);
}
100% {
background-color: rgba(64, 158, 255, 0.15);
}
}
.definition-item.highlight-definition {
animation: highlight-pulse 0.6s ease-in-out 3;
border-color: #409eff;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.3);
}
</style>