Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ up:
down:
docker compose down

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

revision:
docker compose exec backend alembic revision -m "$(name)"

dev:
docker compose -f docker-compose.dev.yaml up --build -d
57 changes: 56 additions & 1 deletion backend/app/api/v1/routes/events.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException, status
import yaml
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
Response,
status,
)
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.modules.events import crud as event_crud
from app.modules.events.schemas import EventCreate, EventOut
from app.modules.events.service import generate_json_schema_for_event
from app.modules.fields.crud import get_fields_by_ids
from app.modules.tags.crud import get_or_create_tags

Expand Down Expand Up @@ -111,3 +121,48 @@ def delete_event_route(event_id: int, db: Session = Depends(get_db)):
if db_event is None:
raise HTTPException(status_code=404, detail="Event not found")
return db_event


@router.get("/{event_id}/schema.json", response_class=JSONResponse)
def get_event_json_schema(
event_id: int,
include_descriptions: bool = Query(True),
include_examples: bool = Query(True),
additional_properties: bool = Query(True),
db: Session = Depends(get_db),
):
db_event = event_crud.get_event(db=db, event_id=event_id)
if not db_event:
raise HTTPException(status_code=404, detail="Event not found")

event = EventOut.model_validate(db_event)
schema = generate_json_schema_for_event(
event,
include_descriptions=include_descriptions,
include_examples=include_examples,
additional_properties=additional_properties,
)
return schema


@router.get("/{event_id}/schema.yaml")
def get_event_yaml_schema(
event_id: int,
include_descriptions: bool = Query(True),
include_examples: bool = Query(True),
additional_properties: bool = Query(True),
db: Session = Depends(get_db),
):
db_event = event_crud.get_event(db=db, event_id=event_id)
if not db_event:
raise HTTPException(status_code=404, detail="Event not found")

event = EventOut.model_validate(db_event)
schema = generate_json_schema_for_event(
event,
include_descriptions=include_descriptions,
include_examples=include_examples,
additional_properties=additional_properties,
)
yaml_data = yaml.dump(schema, sort_keys=False)
return Response(content=yaml_data, media_type="application/x-yaml")
51 changes: 51 additions & 0 deletions backend/app/modules/events/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from app.modules.events.schemas import EventOut


def map_field_type_to_openapi(field_type: str) -> dict:
return {
"string": {"type": "string"},
"integer": {"type": "integer", "format": "int32"},
"number": {"type": "number", "format": "float"},
"boolean": {"type": "boolean"},
"object": {"type": "object"},
"array": {"type": "array"},
}.get(field_type, {"type": "string"})


def generate_json_schema_for_event(
event: EventOut,
*,
additional_properties: bool = True,
include_descriptions: bool = True,
include_examples: bool = True,
) -> dict:
schema = {
"type": "object",
"additionalProperties": additional_properties,
"properties": {},
"required": [],
}

for field in event.fields:
field_type_info = map_field_type_to_openapi(field.field_type)

field_schema = {"type": field_type_info["type"]}
if "format" in field_type_info:
field_schema["format"] = field_type_info["format"]

if include_descriptions and field.description:
field_schema["description"] = field.description

if include_examples and field.example is not None:
field_schema["example"] = field.example

schema["properties"][field.name] = field_schema

# if not field.optional:
if True:
schema["required"].append(field.name)

if not schema["required"]:
del schema["required"]

return schema
4 changes: 2 additions & 2 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--destructive: oklch(0.48 0.18 25.723);
--destructive-foreground: oklch(0.72 0.24 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/modules/events/components/EventDetailsCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const eventExample = useEventExample(props.event.fields)
const emit = defineEmits<{
(e: 'edit'): void
(e: 'delete'): void
(e: 'export'): void
}>()

const { showCopied, showCopyError } = useEnhancedToast()
Expand Down Expand Up @@ -50,6 +51,9 @@ const columns = getEventFieldsColumns()
</template>

<template #actions>
<Button size="icon" variant="ghost" @click="emit('export')">
<Icon icon="radix-icons:share-1" class="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" @click="emit('edit')">
<Icon icon="radix-icons:pencil-2" class="h-4 w-4" />
</Button>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/modules/events/components/EventEditModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ onMounted(() => {
<DialogContent class="max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Event</DialogTitle>
<DialogDescription v-if="description">
<DialogDescription>
{{ description }}
</DialogDescription>
</DialogHeader>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function removeLink(index: number) {
<div
v-for="(link, i) in modelValue"
:key="i"
class="grid grid-cols-[auto_1fr_1fr_auto] items-center gap-2"
class="grid grid-cols-[auto_2fr_1fr_auto] items-center gap-2"
>
<!-- Link Type -->
<Select
Expand All @@ -65,7 +65,7 @@ function removeLink(index: number) {

<!-- Label -->
<Input
placeholder="Label (optional)"
placeholder="Label"
:model-value="link.label"
@update:model-value="val => update(i, { label: String(val) })"
/>
Expand Down
163 changes: 163 additions & 0 deletions frontend/src/modules/events/components/SwaggerExportModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { useClipboard } from '@vueuse/core'
import { useEnhancedToast } from '@/shared/composables/useEnhancedToast'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/shared/ui/dialog'
import { Label } from '@/shared/ui/label'
import { Button } from '@/shared/ui/button'
import { Switch } from '@/shared/ui/switch'
import { api } from '@/shared/utils/api'
import { useAsyncTask } from '@/shared/composables/useAsyncTask'
import { Icon } from '@iconify/vue'

const props = defineProps<{
open: boolean
eventId: number
onClose: () => void
}>()

const settings = reactive({
includeDescriptions: true,
includeExamples: true,
additionalProperties: true,
format: 'yaml' as 'yaml' | 'json',
})

const preview = ref('')
const updates = ref(0)
const { run: fetchSchemaPreview, isLoading } = useAsyncTask()

const { copy: copyJson, isSupported } = useClipboard({ source: preview })
const { showCopied, showCopyError } = useEnhancedToast()
const handleCopy = async () => {
try {
await copyJson()
showCopied('Schema')
} catch {
showCopyError('Schema')
}
}

function handleFetch() {
const params = {
include_descriptions: settings.includeDescriptions,
include_examples: settings.includeExamples,
additional_properties: settings.additionalProperties,
}
const format = settings.format

fetchSchemaPreview(async () => {
const response = await api.get(`/events/${props.eventId}/schema.${format}`, {
params,
responseType: 'text',
})

preview.value =
format === 'json' ? JSON.stringify(JSON.parse(response.data), null, 2) : response.data
updates.value += 1
})
}

watch(
() => props.open,
isOpen => {
if (isOpen) {
preview.value = ''
handleFetch()
}
},
{ immediate: true }
)
</script>

<template>
<Dialog :open="props.open" @update:open="props.onClose">
<DialogContent class="!max-w-4xl">
<DialogHeader>
<DialogTitle>Export Schema</DialogTitle>
<DialogDescription>
Generate a Swagger schema for the event. You can customize the settings below to include
or exclude certain properties.
</DialogDescription>
</DialogHeader>

<div class="mt-4 grid grid-cols-1 gap-8 md:grid-cols-[1fr_2fr]">
<!-- Settings Column -->
<div class="space-y-6">
<div class="flex items-center justify-between">
<Label for="includeDescriptions">Include descriptions</Label>
<Switch id="includeDescriptions" v-model="settings.includeDescriptions" />
</div>

<div class="flex items-center justify-between">
<Label for="includeExamples">Include examples</Label>
<Switch id="includeExamples" v-model="settings.includeExamples" />
</div>

<div class="flex items-center justify-between">
<Label for="additionalProperties">Allow additional properties</Label>
<Switch id="additionalProperties" v-model="settings.additionalProperties" />
</div>

<div class="mt-12 flex items-center justify-between">
<Label for="format">Schema format</Label>
<div class="flex gap-2">
<Button
class="w-16 font-mono"
:variant="settings.format === 'yaml' ? 'default' : 'outline'"
size="sm"
@click="settings.format = 'yaml'"
>
YAML
</Button>
<Button
class="w-16 font-mono"
:variant="settings.format === 'json' ? 'default' : 'outline'"
size="sm"
@click="settings.format = 'json'"
>
JSON
</Button>
</div>
</div>

<div>
<Button variant="secondary" class="w-full" @click="handleFetch" :disabled="isLoading">
Update
</Button>
</div>
</div>

<!-- Preview Column -->
<div>
<!-- <Textarea class="h-[400px] resize-none font-mono text-sm" :value="preview" readonly /> -->

<div
side="bottom"
@click.stop
class="border-muted-foreground bg-foreground text-background relative h-[400px] overflow-y-auto rounded-md border p-4 text-left shadow-sm"
>
<button
v-if="isSupported"
@click="handleCopy"
class="text-muted-foreground absolute top-2 right-2 cursor-pointer text-[10px]"
>
<Icon icon="radix-icons:copy" class="mr-1 inline h-3 w-3" />
</button>
<Transition name="fade-slide" mode="out-in">
<div :key="updates" class="font-mono leading-snug whitespace-pre-wrap select-text">
{{ preview }}
</div>
</Transition>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</template>
9 changes: 9 additions & 0 deletions frontend/src/modules/events/pages/EventDetailsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useAsyncTask } from '@/shared/composables/useAsyncTask'
import type { EventFormValues } from '@/modules/events/validation/eventSchema'
import { useEnhancedToast } from '@/shared/composables/useEnhancedToast'
import DetailsCardSkeleton from '@/shared/components/skeletons/DetailsCardSkeleton.vue'
import SwaggerExportModal from '../components/SwaggerExportModal.vue'

const DeleteModal = defineAsyncComponent(() => import('@/shared/components/modals/DeleteModal.vue'))
const EventEditModal = defineAsyncComponent(
Expand All @@ -21,6 +22,7 @@ const event = ref<Event | null>(null)

const showEditModal = ref(false)
const showDeleteModal = ref(false)
const showSwaggerExportModal = ref(false)

const { run, isLoading } = useAsyncTask()
const { run: runDeleteTask, isLoading: isDeleting } = useAsyncTask()
Expand Down Expand Up @@ -69,6 +71,7 @@ onMounted(() => {
:event="event"
@edit="showEditModal = true"
@delete="showDeleteModal = true"
@export="showSwaggerExportModal = true"
/>

<!-- Modals -->
Expand All @@ -87,5 +90,11 @@ onMounted(() => {
:isDeleting="isDeleting"
description="Once deleted, this event will be removed permanently."
/>
<SwaggerExportModal
v-if="event"
:open="showSwaggerExportModal"
:onClose="() => (showSwaggerExportModal = false)"
:eventId="event.id"
/>
</div>
</template>
Loading