Skip to content

Commit 2541cc6

Browse files
authored
feat: swagger spec for event export
* fix: area desciptions * fix: dark mode destructive colors * fix: links form col widths * feat: export button * refactor: makefile revision * feat: export modal with settings * style
1 parent 3a0c980 commit 2541cc6

13 files changed

Lines changed: 296 additions & 11 deletions

File tree

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ up:
44
down:
55
docker compose down
66

7-
migrate:
7+
migrate: # e. g. make revision name="add auth"
88
docker compose exec backend alembic upgrade head
99

10+
revision:
11+
docker compose exec backend alembic revision -m "$(name)"
12+
1013
dev:
1114
docker compose -f docker-compose.dev.yaml up --build -d

backend/app/api/v1/routes/events.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,19 @@
1-
from fastapi import APIRouter, Depends, HTTPException, status
1+
import yaml
2+
from fastapi import (
3+
APIRouter,
4+
Depends,
5+
HTTPException,
6+
Query,
7+
Response,
8+
status,
9+
)
10+
from fastapi.responses import JSONResponse
211
from sqlalchemy.orm import Session
312

413
from app.core.database import get_db
514
from app.modules.events import crud as event_crud
615
from app.modules.events.schemas import EventCreate, EventOut
16+
from app.modules.events.service import generate_json_schema_for_event
717
from app.modules.fields.crud import get_fields_by_ids
818
from app.modules.tags.crud import get_or_create_tags
919

@@ -111,3 +121,48 @@ def delete_event_route(event_id: int, db: Session = Depends(get_db)):
111121
if db_event is None:
112122
raise HTTPException(status_code=404, detail="Event not found")
113123
return db_event
124+
125+
126+
@router.get("/{event_id}/schema.json", response_class=JSONResponse)
127+
def get_event_json_schema(
128+
event_id: int,
129+
include_descriptions: bool = Query(True),
130+
include_examples: bool = Query(True),
131+
additional_properties: bool = Query(True),
132+
db: Session = Depends(get_db),
133+
):
134+
db_event = event_crud.get_event(db=db, event_id=event_id)
135+
if not db_event:
136+
raise HTTPException(status_code=404, detail="Event not found")
137+
138+
event = EventOut.model_validate(db_event)
139+
schema = generate_json_schema_for_event(
140+
event,
141+
include_descriptions=include_descriptions,
142+
include_examples=include_examples,
143+
additional_properties=additional_properties,
144+
)
145+
return schema
146+
147+
148+
@router.get("/{event_id}/schema.yaml")
149+
def get_event_yaml_schema(
150+
event_id: int,
151+
include_descriptions: bool = Query(True),
152+
include_examples: bool = Query(True),
153+
additional_properties: bool = Query(True),
154+
db: Session = Depends(get_db),
155+
):
156+
db_event = event_crud.get_event(db=db, event_id=event_id)
157+
if not db_event:
158+
raise HTTPException(status_code=404, detail="Event not found")
159+
160+
event = EventOut.model_validate(db_event)
161+
schema = generate_json_schema_for_event(
162+
event,
163+
include_descriptions=include_descriptions,
164+
include_examples=include_examples,
165+
additional_properties=additional_properties,
166+
)
167+
yaml_data = yaml.dump(schema, sort_keys=False)
168+
return Response(content=yaml_data, media_type="application/x-yaml")
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from app.modules.events.schemas import EventOut
2+
3+
4+
def map_field_type_to_openapi(field_type: str) -> dict:
5+
return {
6+
"string": {"type": "string"},
7+
"integer": {"type": "integer", "format": "int32"},
8+
"number": {"type": "number", "format": "float"},
9+
"boolean": {"type": "boolean"},
10+
"object": {"type": "object"},
11+
"array": {"type": "array"},
12+
}.get(field_type, {"type": "string"})
13+
14+
15+
def generate_json_schema_for_event(
16+
event: EventOut,
17+
*,
18+
additional_properties: bool = True,
19+
include_descriptions: bool = True,
20+
include_examples: bool = True,
21+
) -> dict:
22+
schema = {
23+
"type": "object",
24+
"additionalProperties": additional_properties,
25+
"properties": {},
26+
"required": [],
27+
}
28+
29+
for field in event.fields:
30+
field_type_info = map_field_type_to_openapi(field.field_type)
31+
32+
field_schema = {"type": field_type_info["type"]}
33+
if "format" in field_type_info:
34+
field_schema["format"] = field_type_info["format"]
35+
36+
if include_descriptions and field.description:
37+
field_schema["description"] = field.description
38+
39+
if include_examples and field.example is not None:
40+
field_schema["example"] = field.example
41+
42+
schema["properties"][field.name] = field_schema
43+
44+
# if not field.optional:
45+
if True:
46+
schema["required"].append(field.name)
47+
48+
if not schema["required"]:
49+
del schema["required"]
50+
51+
return schema

frontend/src/index.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,8 @@
108108
--muted-foreground: oklch(0.708 0 0);
109109
--accent: oklch(0.269 0 0);
110110
--accent-foreground: oklch(0.985 0 0);
111-
--destructive: oklch(0.396 0.141 25.723);
112-
--destructive-foreground: oklch(0.637 0.237 25.331);
111+
--destructive: oklch(0.48 0.18 25.723);
112+
--destructive-foreground: oklch(0.72 0.24 25.331);
113113
--border: oklch(0.269 0 0);
114114
--input: oklch(0.269 0 0);
115115
--ring: oklch(0.439 0 0);

frontend/src/modules/events/components/EventDetailsCard.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const eventExample = useEventExample(props.event.fields)
2323
const emit = defineEmits<{
2424
(e: 'edit'): void
2525
(e: 'delete'): void
26+
(e: 'export'): void
2627
}>()
2728
2829
const { showCopied, showCopyError } = useEnhancedToast()
@@ -50,6 +51,9 @@ const columns = getEventFieldsColumns()
5051
</template>
5152

5253
<template #actions>
54+
<Button size="icon" variant="ghost" @click="emit('export')">
55+
<Icon icon="radix-icons:share-1" class="h-4 w-4" />
56+
</Button>
5357
<Button size="icon" variant="ghost" @click="emit('edit')">
5458
<Icon icon="radix-icons:pencil-2" class="h-4 w-4" />
5559
</Button>

frontend/src/modules/events/components/EventEditModal.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ onMounted(() => {
4545
<DialogContent class="max-h-[80vh] overflow-y-auto">
4646
<DialogHeader>
4747
<DialogTitle>Edit Event</DialogTitle>
48-
<DialogDescription v-if="description">
48+
<DialogDescription>
4949
{{ description }}
5050
</DialogDescription>
5151
</DialogHeader>

frontend/src/modules/events/components/EventLinksFormField.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ function removeLink(index: number) {
3838
<div
3939
v-for="(link, i) in modelValue"
4040
:key="i"
41-
class="grid grid-cols-[auto_1fr_1fr_auto] items-center gap-2"
41+
class="grid grid-cols-[auto_2fr_1fr_auto] items-center gap-2"
4242
>
4343
<!-- Link Type -->
4444
<Select
@@ -65,7 +65,7 @@ function removeLink(index: number) {
6565

6666
<!-- Label -->
6767
<Input
68-
placeholder="Label (optional)"
68+
placeholder="Label"
6969
:model-value="link.label"
7070
@update:model-value="val => update(i, { label: String(val) })"
7171
/>
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
<script setup lang="ts">
2+
import { ref, reactive, watch } from 'vue'
3+
import { useClipboard } from '@vueuse/core'
4+
import { useEnhancedToast } from '@/shared/composables/useEnhancedToast'
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogHeader,
9+
DialogTitle,
10+
DialogDescription,
11+
} from '@/shared/ui/dialog'
12+
import { Label } from '@/shared/ui/label'
13+
import { Button } from '@/shared/ui/button'
14+
import { Switch } from '@/shared/ui/switch'
15+
import { api } from '@/shared/utils/api'
16+
import { useAsyncTask } from '@/shared/composables/useAsyncTask'
17+
import { Icon } from '@iconify/vue'
18+
19+
const props = defineProps<{
20+
open: boolean
21+
eventId: number
22+
onClose: () => void
23+
}>()
24+
25+
const settings = reactive({
26+
includeDescriptions: true,
27+
includeExamples: true,
28+
additionalProperties: true,
29+
format: 'yaml' as 'yaml' | 'json',
30+
})
31+
32+
const preview = ref('')
33+
const updates = ref(0)
34+
const { run: fetchSchemaPreview, isLoading } = useAsyncTask()
35+
36+
const { copy: copyJson, isSupported } = useClipboard({ source: preview })
37+
const { showCopied, showCopyError } = useEnhancedToast()
38+
const handleCopy = async () => {
39+
try {
40+
await copyJson()
41+
showCopied('Schema')
42+
} catch {
43+
showCopyError('Schema')
44+
}
45+
}
46+
47+
function handleFetch() {
48+
const params = {
49+
include_descriptions: settings.includeDescriptions,
50+
include_examples: settings.includeExamples,
51+
additional_properties: settings.additionalProperties,
52+
}
53+
const format = settings.format
54+
55+
fetchSchemaPreview(async () => {
56+
const response = await api.get(`/events/${props.eventId}/schema.${format}`, {
57+
params,
58+
responseType: 'text',
59+
})
60+
61+
preview.value =
62+
format === 'json' ? JSON.stringify(JSON.parse(response.data), null, 2) : response.data
63+
updates.value += 1
64+
})
65+
}
66+
67+
watch(
68+
() => props.open,
69+
isOpen => {
70+
if (isOpen) {
71+
preview.value = ''
72+
handleFetch()
73+
}
74+
},
75+
{ immediate: true }
76+
)
77+
</script>
78+
79+
<template>
80+
<Dialog :open="props.open" @update:open="props.onClose">
81+
<DialogContent class="!max-w-4xl">
82+
<DialogHeader>
83+
<DialogTitle>Export Schema</DialogTitle>
84+
<DialogDescription>
85+
Generate a Swagger schema for the event. You can customize the settings below to include
86+
or exclude certain properties.
87+
</DialogDescription>
88+
</DialogHeader>
89+
90+
<div class="mt-4 grid grid-cols-1 gap-8 md:grid-cols-[1fr_2fr]">
91+
<!-- Settings Column -->
92+
<div class="space-y-6">
93+
<div class="flex items-center justify-between">
94+
<Label for="includeDescriptions">Include descriptions</Label>
95+
<Switch id="includeDescriptions" v-model="settings.includeDescriptions" />
96+
</div>
97+
98+
<div class="flex items-center justify-between">
99+
<Label for="includeExamples">Include examples</Label>
100+
<Switch id="includeExamples" v-model="settings.includeExamples" />
101+
</div>
102+
103+
<div class="flex items-center justify-between">
104+
<Label for="additionalProperties">Allow additional properties</Label>
105+
<Switch id="additionalProperties" v-model="settings.additionalProperties" />
106+
</div>
107+
108+
<div class="mt-12 flex items-center justify-between">
109+
<Label for="format">Schema format</Label>
110+
<div class="flex gap-2">
111+
<Button
112+
class="w-16 font-mono"
113+
:variant="settings.format === 'yaml' ? 'default' : 'outline'"
114+
size="sm"
115+
@click="settings.format = 'yaml'"
116+
>
117+
YAML
118+
</Button>
119+
<Button
120+
class="w-16 font-mono"
121+
:variant="settings.format === 'json' ? 'default' : 'outline'"
122+
size="sm"
123+
@click="settings.format = 'json'"
124+
>
125+
JSON
126+
</Button>
127+
</div>
128+
</div>
129+
130+
<div>
131+
<Button variant="secondary" class="w-full" @click="handleFetch" :disabled="isLoading">
132+
Update
133+
</Button>
134+
</div>
135+
</div>
136+
137+
<!-- Preview Column -->
138+
<div>
139+
<!-- <Textarea class="h-[400px] resize-none font-mono text-sm" :value="preview" readonly /> -->
140+
141+
<div
142+
side="bottom"
143+
@click.stop
144+
class="border-muted-foreground bg-foreground text-background relative h-[400px] overflow-y-auto rounded-md border p-4 text-left shadow-sm"
145+
>
146+
<button
147+
v-if="isSupported"
148+
@click="handleCopy"
149+
class="text-muted-foreground absolute top-2 right-2 cursor-pointer text-[10px]"
150+
>
151+
<Icon icon="radix-icons:copy" class="mr-1 inline h-3 w-3" />
152+
</button>
153+
<Transition name="fade-slide" mode="out-in">
154+
<div :key="updates" class="font-mono leading-snug whitespace-pre-wrap select-text">
155+
{{ preview }}
156+
</div>
157+
</Transition>
158+
</div>
159+
</div>
160+
</div>
161+
</DialogContent>
162+
</Dialog>
163+
</template>

frontend/src/modules/events/pages/EventDetailsPage.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useAsyncTask } from '@/shared/composables/useAsyncTask'
99
import type { EventFormValues } from '@/modules/events/validation/eventSchema'
1010
import { useEnhancedToast } from '@/shared/composables/useEnhancedToast'
1111
import DetailsCardSkeleton from '@/shared/components/skeletons/DetailsCardSkeleton.vue'
12+
import SwaggerExportModal from '../components/SwaggerExportModal.vue'
1213
1314
const DeleteModal = defineAsyncComponent(() => import('@/shared/components/modals/DeleteModal.vue'))
1415
const EventEditModal = defineAsyncComponent(
@@ -21,6 +22,7 @@ const event = ref<Event | null>(null)
2122
2223
const showEditModal = ref(false)
2324
const showDeleteModal = ref(false)
25+
const showSwaggerExportModal = ref(false)
2426
2527
const { run, isLoading } = useAsyncTask()
2628
const { run: runDeleteTask, isLoading: isDeleting } = useAsyncTask()
@@ -69,6 +71,7 @@ onMounted(() => {
6971
:event="event"
7072
@edit="showEditModal = true"
7173
@delete="showDeleteModal = true"
74+
@export="showSwaggerExportModal = true"
7275
/>
7376

7477
<!-- Modals -->
@@ -87,5 +90,11 @@ onMounted(() => {
8790
:isDeleting="isDeleting"
8891
description="Once deleted, this event will be removed permanently."
8992
/>
93+
<SwaggerExportModal
94+
v-if="event"
95+
:open="showSwaggerExportModal"
96+
:onClose="() => (showSwaggerExportModal = false)"
97+
:eventId="event.id"
98+
/>
9099
</div>
91100
</template>

0 commit comments

Comments
 (0)