WiP
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.12.36" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
// Ensure S8n.Components.Packages assembly is loaded
|
||||
_ = typeof(S8n.Components.Basics.Calculator).Assembly;
|
||||
@@ -16,6 +17,7 @@ var app = builder.Build();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<template>
|
||||
<q-card class="s8n-calculator q-pa-md">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Simple Calculator</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-gutter-md">
|
||||
<div class="s8n-calculator q-pa-md">
|
||||
<div class="q-gutter-md">
|
||||
<q-select
|
||||
outlined
|
||||
v-model="inputs.operator"
|
||||
@@ -49,19 +45,19 @@
|
||||
Result: {{ outputs.result }} ({{ duration }}ms)
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn
|
||||
color="primary"
|
||||
@click="execute('Calc')"
|
||||
:loading="running"
|
||||
size="lg"
|
||||
icon="calculate"
|
||||
label="Calculate"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<div class="row justify-end q-mt-md">
|
||||
<q-btn
|
||||
color="primary"
|
||||
@click="execute('Calc')"
|
||||
:loading="running"
|
||||
size="lg"
|
||||
icon="calculate"
|
||||
label="Calculate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
301
QuasarFrontend/src/components_s8n/ComponentHttpRequest.vue
Normal file
301
QuasarFrontend/src/components_s8n/ComponentHttpRequest.vue
Normal file
@@ -0,0 +1,301 @@
|
||||
<template>
|
||||
<div class="s8n-http-request q-pa-md">
|
||||
<div class="q-gutter-md">
|
||||
<!-- HTTP Method and URL -->
|
||||
<div class="row q-gutter-md">
|
||||
<q-select
|
||||
outlined
|
||||
v-model="inputs.method"
|
||||
label="Method"
|
||||
:options="httpMethods"
|
||||
emit-value
|
||||
map-options
|
||||
class="col-3"
|
||||
/>
|
||||
<q-input
|
||||
outlined
|
||||
v-model="inputs.url"
|
||||
label="URL"
|
||||
placeholder="https://api.example.com/data"
|
||||
class="col"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Headers Section -->
|
||||
<q-expansion-item
|
||||
icon="mdi-format-list-bulleted"
|
||||
label="Headers"
|
||||
caption="Add custom HTTP headers"
|
||||
>
|
||||
<div class="q-gutter-sm q-pa-sm">
|
||||
<div
|
||||
v-for="([key, value], index) in Object.entries(inputs.headers)"
|
||||
:key="index"
|
||||
class="row q-gutter-sm items-center"
|
||||
>
|
||||
<q-input
|
||||
outlined
|
||||
:model-value="key"
|
||||
@update:model-value="updateHeaderKey(index, $event as string)"
|
||||
label="Header Name"
|
||||
dense
|
||||
class="col-4"
|
||||
/>
|
||||
<q-input
|
||||
outlined
|
||||
:model-value="value"
|
||||
@update:model-value="updateHeaderValue(key, $event as string)"
|
||||
label="Header Value"
|
||||
dense
|
||||
class="col"
|
||||
/>
|
||||
<q-btn icon="mdi-delete" color="negative" outline dense @click="removeHeader(key)" />
|
||||
</div>
|
||||
<q-btn icon="mdi-plus" label="Add Header" color="secondary" outline @click="addHeader" />
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Body Section (only for POST, PUT, PATCH) -->
|
||||
<q-expansion-item
|
||||
v-if="showBodyInput"
|
||||
icon="mdi-code-json"
|
||||
label="Request Body"
|
||||
caption="JSON body for POST/PUT/PATCH requests"
|
||||
dense
|
||||
default-opened
|
||||
>
|
||||
<div class="q-pa-sm">
|
||||
<q-input
|
||||
v-model="bodyText"
|
||||
type="textarea"
|
||||
outlined
|
||||
label="JSON Body"
|
||||
placeholder='{"key": "value"}'
|
||||
rows="6"
|
||||
:error="!isValidJson"
|
||||
error-message="Invalid JSON"
|
||||
/>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Result Section -->
|
||||
<div class="result-section q-mt-md">
|
||||
<q-banner v-if="error" class="bg-negative text-white" rounded>
|
||||
{{ error }}
|
||||
</q-banner>
|
||||
<div v-else-if="outputs !== undefined">
|
||||
<q-separator class="q-my-md" />
|
||||
<div class="row items-center q-gutter-sm q-mb-sm">
|
||||
<q-badge :color="getStatusColor(outputs.statusCode)" class="text-h6">
|
||||
{{ outputs.statusCode }} {{ outputs.statusText }}
|
||||
</q-badge>
|
||||
<span class="text-caption">({{ outputs.duration }}ms)</span>
|
||||
</div>
|
||||
|
||||
<!-- Response Headers -->
|
||||
<q-expansion-item
|
||||
v-if="outputs.headers && Object.keys(outputs.headers).length > 0"
|
||||
icon="mdi-format-list-bulleted"
|
||||
label="Response Headers"
|
||||
dense
|
||||
>
|
||||
<q-list dense bordered class="rounded-borders">
|
||||
<q-item v-for="(value, key) in outputs.headers" :key="key">
|
||||
<q-item-section>
|
||||
<q-item-label caption>{{ key }}</q-item-label>
|
||||
<q-item-label>{{ value }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-expansion-item>
|
||||
|
||||
<!-- Response Body -->
|
||||
<q-expansion-item
|
||||
v-if="outputs.response"
|
||||
icon="mdi-code-json"
|
||||
label="Response Body"
|
||||
dense
|
||||
default-opened
|
||||
>
|
||||
<q-input
|
||||
:model-value="formatResponse(outputs.response)"
|
||||
type="textarea"
|
||||
outlined
|
||||
readonly
|
||||
rows="8"
|
||||
/>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row justify-end q-mt-md">
|
||||
<q-btn
|
||||
color="primary"
|
||||
@click="execute('Execute')"
|
||||
:loading="running"
|
||||
size="lg"
|
||||
icon="mdi-send"
|
||||
label="Send Request"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { runtime } from '../components/s8n-runtime';
|
||||
|
||||
interface HttpRequestInputs {
|
||||
method: string;
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
body: object | null;
|
||||
}
|
||||
|
||||
interface HttpRequestOutputs {
|
||||
statusCode: number;
|
||||
statusText: string;
|
||||
response: string;
|
||||
headers: Record<string, string>;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const httpMethods = [
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
{ label: 'PUT', value: 'PUT' },
|
||||
{ label: 'DELETE', value: 'DELETE' },
|
||||
{ label: 'PATCH', value: 'PATCH' },
|
||||
{ label: 'HEAD', value: 'HEAD' },
|
||||
{ label: 'OPTIONS', value: 'OPTIONS' },
|
||||
];
|
||||
|
||||
const bodyText = ref('');
|
||||
|
||||
const { execute, running, error, outputs, inputs } = runtime.createExecutor<
|
||||
HttpRequestInputs,
|
||||
HttpRequestOutputs
|
||||
>('S8n.Components.Basics.HttpRequest', {
|
||||
method: 'GET',
|
||||
url: '',
|
||||
headers: {},
|
||||
body: null,
|
||||
});
|
||||
|
||||
const showBodyInput = computed(() => {
|
||||
const method = inputs.value.method.toUpperCase();
|
||||
return ['POST', 'PUT', 'PATCH'].includes(method);
|
||||
});
|
||||
|
||||
const isValidJson = computed(() => {
|
||||
if (!bodyText.value || bodyText.value.trim() === '') {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
JSON.parse(bodyText.value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Sync bodyText with inputs.body
|
||||
watch(
|
||||
() => inputs.value.body,
|
||||
(newBody) => {
|
||||
if (newBody) {
|
||||
bodyText.value = JSON.stringify(newBody, null, 2);
|
||||
} else {
|
||||
bodyText.value = '';
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Update inputs.body when bodyText changes (if valid JSON)
|
||||
watch(bodyText, (newValue) => {
|
||||
if (!newValue || newValue.trim() === '') {
|
||||
inputs.value.body = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
inputs.value.body = JSON.parse(newValue);
|
||||
} catch {
|
||||
// Invalid JSON, don't update
|
||||
}
|
||||
});
|
||||
|
||||
function addHeader() {
|
||||
// Generate a unique key for the new header
|
||||
const baseKey = 'new-header';
|
||||
let counter = 1;
|
||||
let newKey = baseKey;
|
||||
while (newKey in inputs.value.headers) {
|
||||
newKey = `${baseKey}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
inputs.value.headers[newKey] = '';
|
||||
}
|
||||
|
||||
function removeHeader(key: string) {
|
||||
delete inputs.value.headers[key];
|
||||
// Create a new object to trigger reactivity
|
||||
inputs.value.headers = { ...inputs.value.headers };
|
||||
}
|
||||
|
||||
function updateHeaderKey(oldIndex: number, newKey: string) {
|
||||
const entries = Object.entries(inputs.value.headers);
|
||||
if (oldIndex >= entries.length) return;
|
||||
|
||||
const entry = entries[oldIndex];
|
||||
if (!entry) return;
|
||||
|
||||
const [currentKey, value] = entry;
|
||||
|
||||
// If the new key is empty or same as current, do nothing
|
||||
if (!newKey.trim() || newKey === currentKey) return;
|
||||
|
||||
// Remove the old key and add with new key
|
||||
delete inputs.value.headers[currentKey];
|
||||
inputs.value.headers[newKey] = value;
|
||||
// Create a new object to trigger reactivity
|
||||
inputs.value.headers = { ...inputs.value.headers };
|
||||
}
|
||||
|
||||
function updateHeaderValue(key: string, newValue: string) {
|
||||
if (key in inputs.value.headers) {
|
||||
inputs.value.headers[key] = newValue;
|
||||
// Create a new object to trigger reactivity
|
||||
inputs.value.headers = { ...inputs.value.headers };
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(statusCode: number): string {
|
||||
if (statusCode >= 200 && statusCode < 300) return 'positive';
|
||||
if (statusCode >= 300 && statusCode < 400) return 'info';
|
||||
if (statusCode >= 400 && statusCode < 500) return 'warning';
|
||||
if (statusCode >= 500) return 'negative';
|
||||
return 'grey';
|
||||
}
|
||||
|
||||
function formatResponse(response: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(response);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.s8n-http-request {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
|
||||
.result-section {
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -29,6 +29,18 @@ import { ref } from 'vue';
|
||||
import EssentialLink, { type EssentialLinkProps } from 'components/EssentialLink.vue';
|
||||
|
||||
const linksList: EssentialLinkProps[] = [
|
||||
{
|
||||
title: 'Home',
|
||||
caption: 'Main page',
|
||||
icon: 'home',
|
||||
link: '/',
|
||||
},
|
||||
{
|
||||
title: 'Playground',
|
||||
caption: 'S8n Components',
|
||||
icon: 'mdi-cube-outline',
|
||||
link: '/#/playground',
|
||||
},
|
||||
{
|
||||
title: 'Docs',
|
||||
caption: 'quasar.dev',
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ComponentCalculator from 'components/ComponentCalculator.vue';
|
||||
import ComponentCalculator from 'src/components_s8n/ComponentCalculator.vue';
|
||||
</script>
|
||||
|
||||
136
QuasarFrontend/src/pages/PlaygroundPage.vue
Normal file
136
QuasarFrontend/src/pages/PlaygroundPage.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<q-page class="playground-page">
|
||||
<div class="row q-pa-md q-gutter-md">
|
||||
<!-- Component Selection Sidebar -->
|
||||
<div class="col-12 col-md-3">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Components</div>
|
||||
<div class="text-caption text-grey">Select a component to interact with</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-list separator>
|
||||
<q-item
|
||||
v-for="component in availableComponents"
|
||||
:key="component.code"
|
||||
clickable
|
||||
:active="selectedComponent === component.code"
|
||||
@click="selectedComponent = component.code"
|
||||
active-class="bg-primary text-white"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-icon :name="component.icon" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ component.name }}</q-item-label>
|
||||
<q-item-label caption :class="selectedComponent === component.code ? 'text-white' : 'text-grey'">
|
||||
{{ component.description }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card>
|
||||
|
||||
<!-- Component Info Card -->
|
||||
<q-card class="q-mt-md" v-if="currentComponentInfo">
|
||||
<q-card-section>
|
||||
<div class="text-subtitle2">Component Info</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-caption">
|
||||
<strong>Code:</strong> {{ currentComponentInfo.code }}
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
<strong>Class:</strong> {{ currentComponentInfo.className }}
|
||||
</div>
|
||||
<div class="text-caption">
|
||||
<strong>Method:</strong> {{ currentComponentInfo.method }}
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Main Component Area -->
|
||||
<div class="col-12 col-md">
|
||||
<q-card class="full-height">
|
||||
<q-card-section>
|
||||
<div class="text-h6">{{ currentComponentInfo?.name || 'Select a Component' }}</div>
|
||||
<div class="text-caption text-grey" v-if="currentComponentInfo">
|
||||
{{ currentComponentInfo.description }}
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pa-md">
|
||||
<component
|
||||
:is="currentComponentComponent"
|
||||
v-if="currentComponentComponent"
|
||||
/>
|
||||
<div v-else class="text-center text-grey q-pa-xl">
|
||||
<q-icon name="mdi-cube-outline" size="64px" class="q-mb-md" />
|
||||
<div class="text-h6">Welcome to S8n Playground</div>
|
||||
<div class="text-body1">
|
||||
Select a component from the sidebar to start interacting with it.
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, defineAsyncComponent } from 'vue';
|
||||
|
||||
interface ComponentInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
className: string;
|
||||
method: string;
|
||||
component: ReturnType<typeof defineAsyncComponent>;
|
||||
}
|
||||
|
||||
const selectedComponent = ref<string>('');
|
||||
|
||||
// Define available components
|
||||
const availableComponents: ComponentInfo[] = [
|
||||
{
|
||||
code: 'calculator',
|
||||
name: 'Calculator',
|
||||
description: 'Perform mathematical operations on numbers',
|
||||
icon: 'mdi-calculator',
|
||||
className: 'S8n.Components.Basics.Calculator',
|
||||
method: 'Calc',
|
||||
component: defineAsyncComponent(() => import('../components_s8n/ComponentCalculator.vue')),
|
||||
},
|
||||
{
|
||||
code: 'httprequest',
|
||||
name: 'HTTP Request',
|
||||
description: 'Make HTTP requests to any URL',
|
||||
icon: 'mdi-web',
|
||||
className: 'S8n.Components.Basics.HttpRequest',
|
||||
method: 'Execute',
|
||||
component: defineAsyncComponent(() => import('../components_s8n/ComponentHttpRequest.vue')),
|
||||
},
|
||||
];
|
||||
|
||||
const currentComponentInfo = computed(() => {
|
||||
return availableComponents.find(c => c.code === selectedComponent.value);
|
||||
});
|
||||
|
||||
const currentComponentComponent = computed(() => {
|
||||
return currentComponentInfo.value?.component;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.playground-page {
|
||||
min-height: calc(100vh - 50px);
|
||||
|
||||
.full-height {
|
||||
min-height: calc(100vh - 100px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,9 +4,11 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('layouts/MainLayout.vue'),
|
||||
children: [{ path: '', component: () => import('pages/IndexPage.vue') }],
|
||||
children: [
|
||||
{ path: '', component: () => import('pages/IndexPage.vue') },
|
||||
{ path: 'playground', component: () => import('pages/PlaygroundPage.vue') },
|
||||
],
|
||||
},
|
||||
|
||||
// Always leave this as last one,
|
||||
// but you can also remove it
|
||||
{
|
||||
|
||||
121
S8n.Components.Packages/Basics/HttpRequest.cs
Normal file
121
S8n.Components.Packages/Basics/HttpRequest.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace S8n.Components.Basics;
|
||||
|
||||
public class HttpRequest
|
||||
{
|
||||
private static readonly HttpClient _httpClient = new();
|
||||
|
||||
private static string EnsureUrlSchema(string url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return url;
|
||||
|
||||
// Check if the URL already has a scheme
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && !string.IsNullOrEmpty(uri.Scheme))
|
||||
{
|
||||
return url; // Already has a scheme
|
||||
}
|
||||
|
||||
// If no scheme, prepend http://
|
||||
// Check if it starts with // (protocol-relative URL)
|
||||
if (url.StartsWith("//"))
|
||||
{
|
||||
return "http:" + url;
|
||||
}
|
||||
|
||||
// Otherwise prepend http://
|
||||
return "http://" + url;
|
||||
}
|
||||
|
||||
public object Execute(string method, string url, Dictionary<string, string>? headers = null, object? body = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
throw new ArgumentException("URL is required");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(method))
|
||||
{
|
||||
throw new ArgumentException("HTTP method is required");
|
||||
}
|
||||
|
||||
var normalizedUrl = EnsureUrlSchema(url);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Parse(method), normalizedUrl);
|
||||
|
||||
// Add headers if provided
|
||||
if (headers != null)
|
||||
{
|
||||
foreach (var header in headers)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add body if provided and method supports it
|
||||
if (body != null && (method.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
|
||||
method.Equals("PUT", StringComparison.OrdinalIgnoreCase) ||
|
||||
method.Equals("PATCH", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
string jsonBody;
|
||||
if (body is JsonElement jsonElement)
|
||||
{
|
||||
jsonBody = jsonElement.GetRawText();
|
||||
}
|
||||
else
|
||||
{
|
||||
jsonBody = JsonSerializer.Serialize(body);
|
||||
}
|
||||
request.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
// Execute request
|
||||
using var response = _httpClient.SendAsync(request).GetAwaiter().GetResult();
|
||||
stopwatch.Stop();
|
||||
|
||||
// Read response content
|
||||
var responseContent = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Extract response headers
|
||||
var responseHeaders = new Dictionary<string, string>();
|
||||
foreach (var header in response.Headers)
|
||||
{
|
||||
responseHeaders[header.Key] = string.Join(", ", header.Value);
|
||||
}
|
||||
foreach (var header in response.Content.Headers)
|
||||
{
|
||||
responseHeaders[header.Key] = string.Join(", ", header.Value);
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
StatusCode = (int)response.StatusCode,
|
||||
StatusText = response.StatusCode.ToString(),
|
||||
Response = responseContent,
|
||||
Headers = responseHeaders,
|
||||
Duration = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
throw new Exception($"HTTP request failed: {ex.Message}");
|
||||
}
|
||||
catch (UriFormatException ex)
|
||||
{
|
||||
throw new Exception($"Invalid URL format: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (ex is not ArgumentException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
throw new Exception($"Request failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Submodule components updated: 2ccd4e998b...af003c180d
178
plans/scalar-httprequest-playground.md
Normal file
178
plans/scalar-httprequest-playground.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Implementation Plan: Replace Swagger with Scalar, Add HttpRequest Component, Create PlaygroundPage
|
||||
|
||||
## Overview
|
||||
This plan outlines the steps to accomplish three main tasks:
|
||||
1. Replace the current OpenAPI UI (Swagger) with Scalar API reference UI in the backend.
|
||||
2. Add a new HttpRequest component (similar to Calculator) for making HTTP requests.
|
||||
3. Create a PlaygroundPage.vue for dynamic s8n components manipulation.
|
||||
|
||||
## Task 1: Replace Swagger with Scalar
|
||||
|
||||
### Current State
|
||||
- Backend uses `Microsoft.AspNetCore.OpenApi` package (built-in OpenAPI).
|
||||
- OpenAPI endpoint is mapped via `app.MapOpenApi()` in development environment.
|
||||
- No Swashbuckle/Swagger UI currently installed.
|
||||
|
||||
### Requirements
|
||||
- Integrate Scalar as middleware to serve a modern API reference UI.
|
||||
- Maintain OpenAPI specification generation (already provided by `AddOpenApi`).
|
||||
- Scalar UI should be accessible at a dedicated route (e.g., `/scalar`).
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
#### 1.1 Add Scalar.AspNetCore NuGet Package
|
||||
Add package reference to `MyCompany.MyProject.BackendApi.csproj`:
|
||||
```xml
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.0.0" />
|
||||
```
|
||||
|
||||
#### 1.2 Configure Scalar Middleware
|
||||
Modify `Program.cs`:
|
||||
- Keep `builder.Services.AddOpenApi()` for OpenAPI spec generation.
|
||||
- Replace `app.MapOpenApi()` with Scalar middleware in development environment.
|
||||
- Configure Scalar to use the generated OpenAPI spec.
|
||||
|
||||
Example code:
|
||||
```csharp
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapScalarApiReference();
|
||||
// Optional: keep MapOpenApi if needed for raw spec
|
||||
app.MapOpenApi();
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 Customize Scalar Settings (Optional)
|
||||
Configure Scalar options (title, theme, etc.) via `AddScalar`.
|
||||
|
||||
### Expected Outcome
|
||||
- Visiting `/scalar` in browser displays Scalar API reference UI.
|
||||
- OpenAPI spec remains available at `/openapi/v1.json`.
|
||||
|
||||
## Task 2: Add HttpRequest Component
|
||||
|
||||
### Component Structure
|
||||
|
||||
#### 2.1 C# Backend Component
|
||||
- **Location**: `S8n.Components.Packages/Basics/HttpRequest.cs`
|
||||
- **Namespace**: `S8n.Components.Basics`
|
||||
- **Class**: `HttpRequest`
|
||||
- **Method**: `Execute` (or `Request`)
|
||||
- **Inputs**:
|
||||
- `method` (string): HTTP method (GET, POST, PUT, DELETE, etc.)
|
||||
- `url` (string): Target URL
|
||||
- `headers` (Dictionary<string, string>): Optional request headers
|
||||
- `body` (object): Optional request body (serialized as JSON)
|
||||
- **Outputs**:
|
||||
- `statusCode` (int): HTTP status code
|
||||
- `response` (string): Response body as string
|
||||
- `headers` (Dictionary<string, string>): Response headers
|
||||
- `duration` (long): Request duration in milliseconds
|
||||
|
||||
Implementation will use `HttpClient` to make the request.
|
||||
|
||||
#### 2.2 Vue Frontend Component
|
||||
- **Location**: `QuasarFrontend/src/components_s8n/ComponentHttpRequest.vue`
|
||||
- **Pattern**: Follow `ComponentCalculator.vue` structure.
|
||||
- **UI Elements**:
|
||||
- Dropdown for HTTP method (GET, POST, PUT, DELETE, etc.)
|
||||
- Text input for URL
|
||||
- Dynamic key-value pair inputs for headers (add/remove)
|
||||
- JSON editor for request body (textarea with JSON validation)
|
||||
- Execute button with loading state
|
||||
- Display response status, headers, body, and duration
|
||||
|
||||
#### 2.3 Component Definition Markdown
|
||||
- **Location**: `components/basics/httprequest.md`
|
||||
- **Format**: Similar to `calculator.md` with appropriate inputs/outputs.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
#### 2.4 Create C# Class
|
||||
1. Create `HttpRequest.cs` file.
|
||||
2. Implement method with error handling (timeout, invalid URL, etc.).
|
||||
3. Ensure JSON serialization/deserialization.
|
||||
|
||||
#### 2.5 Create Vue Component
|
||||
1. Create `ComponentHttpRequest.vue`.
|
||||
2. Use `runtime.createExecutor` with appropriate types.
|
||||
3. Design UI using Quasar components.
|
||||
|
||||
#### 2.6 Create Markdown Definition
|
||||
1. Create `httprequest.md` with YAML definition.
|
||||
|
||||
#### 2.7 Register Component (if needed)
|
||||
- The runtime automatically discovers classes via reflection; no explicit registration required.
|
||||
|
||||
## Task 3: Create PlaygroundPage.vue
|
||||
|
||||
### Requirements
|
||||
- A dedicated page for interacting with s8n components.
|
||||
- Dynamic component selection and rendering.
|
||||
- Support for multiple components (Calculator, HttpRequest, future components).
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
#### 3.1 Add Route
|
||||
Modify `QuasarFrontend/src/router/routes.ts`:
|
||||
```typescript
|
||||
{
|
||||
path: '/playground',
|
||||
component: () => import('layouts/MainLayout.vue'),
|
||||
children: [{ path: '', component: () => import('pages/PlaygroundPage.vue') }],
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Create PlaygroundPage.vue
|
||||
- **Location**: `QuasarFrontend/src/pages/PlaygroundPage.vue`
|
||||
- **Structure**:
|
||||
- Left sidebar: List of available components (hardcoded for now).
|
||||
- Main content: Dynamic component render area.
|
||||
- Output display area (could be integrated into each component).
|
||||
|
||||
#### 3.3 Component Registry
|
||||
Create a component registry file (`src/components_s8n/registry.ts`) that exports:
|
||||
- Component definitions (name, description, Vue component import).
|
||||
- Dynamic import of components.
|
||||
|
||||
#### 3.4 Dynamic Component Loading
|
||||
Use Vue's `<component :is="selectedComponent">` to render selected component.
|
||||
|
||||
#### 3.5 UI Enhancements
|
||||
- Use Quasar's `q-select` for component selection.
|
||||
- Provide a clean layout with card containers.
|
||||
|
||||
## Dependencies and Considerations
|
||||
|
||||
### Backend Dependencies
|
||||
- `Scalar.AspNetCore` (new)
|
||||
- `Microsoft.AspNetCore.OpenApi` (already present)
|
||||
- `System.Net.Http` (for HttpRequest component)
|
||||
|
||||
### Frontend Dependencies
|
||||
- None new; use existing Quasar and Vue.
|
||||
|
||||
### Testing
|
||||
- Test Scalar UI loads correctly.
|
||||
- Test HttpRequest component with mock HTTP server.
|
||||
- Test PlaygroundPage navigation and component switching.
|
||||
|
||||
## Timeline and Priority
|
||||
1. **Priority 1**: HttpRequest component (core functionality).
|
||||
2. **Priority 2**: PlaygroundPage (user interface).
|
||||
3. **Priority 3**: Scalar integration (documentation).
|
||||
|
||||
## Success Criteria
|
||||
- Scalar UI accessible at `/scalar` (development only).
|
||||
- HttpRequest component works for basic GET/POST requests.
|
||||
- PlaygroundPage renders Calculator and HttpRequest components.
|
||||
- No regression in existing Calculator component.
|
||||
|
||||
## Notes
|
||||
- Scalar integration may require configuration for production; currently planned for development only.
|
||||
- HttpRequest component should handle CORS limitations (backend acts as proxy).
|
||||
- PlaygroundPage can be extended later with component discovery from backend API.
|
||||
|
||||
## Next Steps
|
||||
1. Review this plan with stakeholders.
|
||||
2. Proceed to implementation in Code mode.
|
||||
Reference in New Issue
Block a user