diff --git a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.html b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.html
index da274cff..9003c18f 100644
--- a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.html
+++ b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.html
@@ -32,4 +32,54 @@
+
+
+
+
{{ 'apiHealthBanner.loading' | transloco }}
+
+
+
{{ 'apiHealthBanner.healthy' | transloco }}
+
+
+
+ {{ 'apiHealthBanner.unhealthyBase' | transloco }}
+
+
+
+
+
+
+ {{ 'apiHealthBanner.warningDefault' | transloco }}
+
+
+
+
+
+
+
diff --git a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.scss b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.scss
index 45708b99..f2cbf028 100644
--- a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.scss
+++ b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.scss
@@ -41,3 +41,106 @@
.swagger-ui {
margin-top: 16px;
}
+
+.api-health-banner {
+ display: flex;
+ align-items: center;
+ padding: 8px 12px;
+ border-radius: 4px;
+ border-left-width: 4px;
+ border-left-style: solid;
+
+ p {
+ margin: 0;
+ font-size: 0.9em;
+ }
+
+ &.status-healthy {
+ border-left-color: #28a745;
+ background-color: #e9f5ec;
+ color: #155724;
+ }
+
+ &.status-unhealthy,
+ &.status-error {
+ border-left-color: #dc3545;
+ background-color: #f8d7da;
+ color: #721c24;
+ }
+
+ &.status-unhealthy {
+ & > div {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ width: 100%;
+
+ & > p {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+
+ .view-details-button {
+ margin-left: 12px;
+ flex-shrink: 0;
+ padding: 2px 8px;
+ line-height: normal;
+ font-size: 0.9em;
+ min-width: auto;
+ }
+ }
+
+ .unhealthy-error-details {
+ margin-top: 0;
+ padding: 8px 12px;
+ background-color: rgba(0, 0, 0, 0.03);
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ border-radius: 4px;
+ width: 100%;
+ box-sizing: border-box;
+ max-height: 150px;
+ overflow-y: auto;
+
+ pre {
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-size: 0.85em;
+ color: inherit;
+ }
+ }
+ }
+ }
+
+ &.status-loading {
+ border-left-color: #007bff;
+ background-color: #e7f3ff;
+ color: #004085;
+ }
+
+ &.status-warning {
+ border-left-color: #ffc107;
+ background-color: #fff3cd;
+ color: #856404;
+ }
+}
+
+// Styles for elements within Swagger UI, piercing encapsulation
+:host ::ng-deep {
+ .swagger-ui {
+ // This targets the wrapper div for Swagger UI in your component's template
+ .information-container {
+ .main {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+ }
+ }
+}
+
+.custom-swagger-content-wrapper {
+ width: 100%;
+}
diff --git a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.ts b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.ts
index 8d805e37..26a293fa 100644
--- a/src/app/adf-api-docs/df-api-docs/df-api-docs.component.ts
+++ b/src/app/adf-api-docs/df-api-docs/df-api-docs.component.ts
@@ -25,19 +25,31 @@ import {
mapSnakeToCamel,
} from 'src/app/shared/utilities/case';
import { DfThemeService } from 'src/app/shared/services/df-theme.service';
-import { AsyncPipe, NgIf, NgFor, SlicePipe } from '@angular/common';
+import { AsyncPipe, NgIf, NgFor, SlicePipe, NgClass } from '@angular/common';
import { environment } from '../../../../environments/environment';
import { ApiKeysService } from '../services/api-keys.service';
import { ApiKeyInfo } from 'src/app/shared/types/api-keys';
import { Clipboard } from '@angular/cdk/clipboard';
import { MatSnackBar } from '@angular/material/snack-bar';
+import { MatListModule } from '@angular/material/list';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { MatExpansionModule } from '@angular/material/expansion';
+import { MatCardModule } from '@angular/material/card';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faCopy } from '@fortawesome/free-solid-svg-icons';
import { DfCurrentServiceService } from 'src/app/shared/services/df-current-service.service';
-import { tap, switchMap, map, distinctUntilChanged } from 'rxjs/operators';
-import { HttpClient } from '@angular/common/http';
+import {
+ tap,
+ switchMap,
+ map,
+ distinctUntilChanged,
+ catchError,
+} from 'rxjs/operators';
+import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { BASE_URL } from 'src/app/shared/constants/urls';
-import { Subscription } from 'rxjs';
+import { Subscription, of, forkJoin } from 'rxjs';
+import { DfApiQuickstartComponent } from '../df-api-quickstart/df-api-quickstart.component';
+import { ApiDocJson } from 'src/app/shared/types/files';
interface ServiceResponse {
resource: Array<{
@@ -47,6 +59,12 @@ interface ServiceResponse {
}>;
}
+interface HealthCheckResult {
+ endpoint: string;
+ success?: boolean;
+ error?: string;
+}
+
@UntilDestroy({ checkProperties: true })
@Component({
selector: 'df-api-docs',
@@ -63,18 +81,60 @@ interface ServiceResponse {
NgIf,
NgFor,
SlicePipe,
+ NgClass,
FontAwesomeModule,
+ MatListModule,
+ MatTooltipModule,
+ MatExpansionModule,
+ MatCardModule,
+ DfApiQuickstartComponent,
],
})
export class DfApiDocsComponent implements OnInit, AfterContentInit, OnDestroy {
@ViewChild('apiDocumentation', { static: true }) apiDocElement:
| ElementRef
| undefined;
+ @ViewChild('swaggerInjectedContentContainer')
+ swaggerInjectedContentContainerRef: ElementRef | undefined;
+ @ViewChild('healthBannerElement') healthBannerElementRef:
+ | ElementRef
+ | undefined;
- apiDocJson: object;
+ apiDocJson: ApiDocJson;
apiKeys: ApiKeyInfo[] = [];
faCopy = faCopy;
+
private subscriptions: Subscription[] = [];
+ healthStatus: 'loading' | 'healthy' | 'unhealthy' | 'warning' = 'loading';
+ healthError: string | null = null;
+ serviceName: string | null = null;
+ showUnhealthyErrorDetails = false;
+ // Mapping of service types to their corresponding endpoints, probably would be better to move to the back-end
+ healthCheckEndpointsInfo: {
+ [key: string]: { endpoint: string; title: string; description: string }[];
+ } = {
+ Database: [
+ {
+ endpoint: '/_schema',
+ title: 'View Available Schemas',
+ description:
+ 'This command fetches a list of schemas from your connected database',
+ },
+ {
+ endpoint: '/_table',
+ title: 'View Tables in Your Database',
+ description: 'This command lists all tables in your database',
+ },
+ ],
+ File: [
+ {
+ endpoint: '/',
+ title: 'View Available Folders',
+ description:
+ 'This command fetches a list of folders from your connected file storage',
+ },
+ ],
+ };
constructor(
private activatedRoute: ActivatedRoute,
@@ -90,14 +150,14 @@ export class DfApiDocsComponent implements OnInit, AfterContentInit, OnDestroy {
isDarkMode = this.themeService.darkMode$;
ngOnInit(): void {
// Get the service name from the route
- const serviceName = this.activatedRoute.snapshot.params['name'];
+ this.serviceName = this.activatedRoute.snapshot.params['name'];
// First fetch the service ID by name
- if (serviceName) {
+ if (this.serviceName) {
this.subscriptions.push(
this.http
.get(
- `${BASE_URL}/system/service?filter=name=${serviceName}`
+ `${BASE_URL}/system/service?filter=name=${this.serviceName}`
)
.pipe(
map(response => response?.resource?.[0]?.id || -1),
@@ -115,11 +175,7 @@ export class DfApiDocsComponent implements OnInit, AfterContentInit, OnDestroy {
this.subscriptions.push(
this.activatedRoute.data.subscribe(({ data }) => {
if (data) {
- if (
- data.paths['/']?.get &&
- data.paths['/']?.get.operationId &&
- data.paths['/']?.get.operationId === 'getSoapResources'
- ) {
+ if (data.paths['/']?.get?.operationId === 'getSoapResources') {
this.apiDocJson = { ...data, paths: mapSnakeToCamel(data.paths) };
} else {
this.apiDocJson = { ...data, paths: mapCamelToSnake(data.paths) };
@@ -146,6 +202,8 @@ export class DfApiDocsComponent implements OnInit, AfterContentInit, OnDestroy {
ngAfterContentInit(): void {
const apiDocumentation = this.apiDocJson;
+ this.checkApiHealth();
+
SwaggerUI({
spec: apiDocumentation,
domNode: this.apiDocElement?.nativeElement,
@@ -165,6 +223,28 @@ export class DfApiDocsComponent implements OnInit, AfterContentInit, OnDestroy {
return req;
},
showMutatedRequest: true,
+ onComplete: () => {
+ if (
+ this.apiDocElement &&
+ this.apiDocElement.nativeElement &&
+ this.swaggerInjectedContentContainerRef &&
+ this.swaggerInjectedContentContainerRef.nativeElement
+ ) {
+ const swaggerContainer = this.apiDocElement.nativeElement;
+ const customContentNode =
+ this.swaggerInjectedContentContainerRef.nativeElement;
+
+ const infoContainer = swaggerContainer.querySelector(
+ '.information-container .main'
+ );
+
+ this.injectCustomContent(
+ swaggerContainer,
+ infoContainer,
+ customContentNode
+ );
+ }
+ },
});
}
@@ -173,6 +253,51 @@ export class DfApiDocsComponent implements OnInit, AfterContentInit, OnDestroy {
this.subscriptions.forEach(sub => sub.unsubscribe());
}
+ private checkApiHealth(): void {
+ let endpointsInfoToValidate =
+ this.healthCheckEndpointsInfo[this.apiDocJson.info.group];
+ if (this.serviceName && endpointsInfoToValidate) {
+ // Perform health check
+ this.performHealthCheck(endpointsInfoToValidate[0].endpoint);
+ } else {
+ this.setHealthState('warning');
+ }
+ }
+
+ private setHealthState(
+ status: 'healthy' | 'unhealthy' | 'warning',
+ error: string | null = null
+ ): void {
+ this.healthStatus = status;
+ this.healthError = error;
+ }
+
+ private performHealthCheck(endpoint: string): void {
+ this.healthStatus = 'loading';
+ this.healthError = null;
+
+ this.subscriptions.push(
+ this.http
+ .get(`${BASE_URL}/${this.serviceName}${endpoint}`, {
+ responseType: 'text',
+ })
+ .pipe(
+ tap(() => this.setHealthState('healthy')),
+ catchError((error: HttpErrorResponse) => {
+ this.setHealthState(
+ 'unhealthy',
+ `${endpoint}: ${
+ error.message || error.error.message || 'Unknown error'
+ }`
+ );
+
+ return of(null);
+ })
+ )
+ .subscribe()
+ );
+ }
+
goBackToList(): void {
this.currentServiceService.clearCurrentServiceId();
this.router.navigate(['../'], { relativeTo: this.activatedRoute });
@@ -188,8 +313,31 @@ export class DfApiDocsComponent implements OnInit, AfterContentInit, OnDestroy {
copyApiKey(key: string) {
this.clipboard.copy(key);
- this.snackBar.open('API Key copied to clipboard', 'Close', {
- duration: 3000,
+ this.snackBar.open('API Key copied to clipboard!', 'Close', {
+ duration: 2000,
});
}
+
+ toggleUnhealthyErrorDetails(): void {
+ this.showUnhealthyErrorDetails = !this.showUnhealthyErrorDetails;
+ }
+
+ private injectCustomContent(
+ swaggerContainer: HTMLElement,
+ infoContainer: HTMLElement | null,
+ customContentNode: HTMLElement
+ ): void {
+ if (infoContainer) {
+ infoContainer.appendChild(customContentNode);
+ } else {
+ if (swaggerContainer.firstChild) {
+ swaggerContainer.insertBefore(
+ customContentNode,
+ swaggerContainer.firstChild
+ );
+ } else {
+ swaggerContainer.appendChild(customContentNode);
+ }
+ }
+ }
}
diff --git a/src/app/adf-api-docs/df-api-quickstart/df-api-quickstart.component.html b/src/app/adf-api-docs/df-api-quickstart/df-api-quickstart.component.html
new file mode 100644
index 00000000..0d73cff7
--- /dev/null
+++ b/src/app/adf-api-docs/df-api-quickstart/df-api-quickstart.component.html
@@ -0,0 +1,55 @@
+
+
+
+
+ {{ 'apiBasicCurlCommands.title' | transloco }}
+
+
+
+ 0">
+
+ {{ 'apiBasicCurlCommands.quickStartDetails' | transloco }}
+
+
+
+ {{ i + 1 }}. {{ command.title }}
+
+
+ {{ command.description }}
+
+
+
+ {{ command.textForDisplay }}
+
+
+
+
+
+
{{ command.note }}
+
+
+
+
+ {{
+ 'apiBasicCurlCommands.nextStepFooter.header' | transloco
+ }}
+ {{ 'apiBasicCurlCommands.nextStepFooter.body' | transloco }}
+
+
+
+
diff --git a/src/app/adf-api-docs/df-api-quickstart/df-api-quickstart.component.scss b/src/app/adf-api-docs/df-api-quickstart/df-api-quickstart.component.scss
new file mode 100644
index 00000000..d6e9a152
--- /dev/null
+++ b/src/app/adf-api-docs/df-api-quickstart/df-api-quickstart.component.scss
@@ -0,0 +1,62 @@
+mat-expansion-panel-header {
+ padding: 0 12px;
+}
+.curl-command-text {
+ white-space: pre;
+ font-family: monospace;
+ font-size: 0.9em;
+ margin: 0;
+ color: var(--df-script-editor-text-color);
+ overflow-x: auto;
+}
+
+.curl-commands-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ .actions-container {
+ padding: 0px 8px;
+ }
+}
+
+.curl-command-title {
+ margin: 0;
+ font-weight: bold;
+}
+
+.curl-command-note {
+ color: gray !important;
+}
+
+.no-commands-container {
+ ul {
+ padding-left: 20px;
+ li {
+ margin-bottom: 10px;
+ }
+ }
+
+ span[class^='method-'] {
+ font-weight: bold;
+ font-family: monospace;
+ padding: 2px 6px;
+ border-radius: 4px;
+ color: white;
+ }
+
+ .method-get {
+ background-color: #61affe; // blue
+ }
+ .method-post {
+ background-color: #49cc90; // green
+ }
+ .method-put {
+ background-color: #fca130; // orange
+ }
+ .method-patch {
+ background-color: #fca130; // orange
+ }
+ .method-delete {
+ background-color: #f93e3e; // red
+ }
+}
diff --git a/src/app/adf-api-docs/df-api-quickstart/df-api-quickstart.component.ts b/src/app/adf-api-docs/df-api-quickstart/df-api-quickstart.component.ts
new file mode 100644
index 00000000..6a97d20e
--- /dev/null
+++ b/src/app/adf-api-docs/df-api-quickstart/df-api-quickstart.component.ts
@@ -0,0 +1,127 @@
+import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { TranslocoModule } from '@ngneat/transloco';
+import { MatExpansionModule } from '@angular/material/expansion';
+import { MatCardModule } from '@angular/material/card';
+import { MatIconModule } from '@angular/material/icon';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { MatButtonModule } from '@angular/material/button';
+import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
+import { faCopy } from '@fortawesome/free-solid-svg-icons';
+import { Clipboard } from '@angular/cdk/clipboard';
+import { DfUserDataService } from 'src/app/shared/services/df-user-data.service';
+import { BASE_URL } from 'src/app/shared/constants/urls';
+import { SESSION_TOKEN_HEADER } from 'src/app/shared/constants/http-headers';
+import { ApiDocJson } from 'src/app/shared/types/files';
+import { MatDividerModule } from '@angular/material/divider';
+import { MatSnackBar } from '@angular/material/snack-bar';
+
+interface CurlCommand {
+ title: string;
+ description: string;
+ textForDisplay: string;
+ textForCopy: string;
+ note: string;
+}
+
+const healthCheckEndpointsInfo: {
+ [key: string]: { endpoint: string; title: string; description: string }[];
+} = {
+ Database: [
+ {
+ endpoint: '/_schema',
+ title: 'View Available Schemas',
+ description:
+ 'This command fetches a list of schemas from your connected database',
+ },
+ {
+ endpoint: '/_table',
+ title: 'View Tables in Your Database',
+ description: 'This command lists all tables in your database',
+ },
+ ],
+ File: [
+ {
+ endpoint: '/',
+ title: 'View Available Folders',
+ description:
+ 'This command fetches a list of folders from your connected file storage',
+ },
+ ],
+};
+
+@Component({
+ selector: 'df-api-quickstart',
+ templateUrl: './df-api-quickstart.component.html',
+ styleUrls: ['./df-api-quickstart.component.scss'],
+ standalone: true,
+ imports: [
+ CommonModule,
+ TranslocoModule,
+ MatExpansionModule,
+ MatCardModule,
+ MatIconModule,
+ MatTooltipModule,
+ FontAwesomeModule,
+ MatDividerModule,
+ MatButtonModule,
+ ],
+})
+export class DfApiQuickstartComponent implements OnChanges {
+ @Input() apiDocJson: ApiDocJson;
+ @Input() serviceName: string;
+
+ curlCommands: CurlCommand[] = [];
+ faCopy = faCopy;
+
+ constructor(
+ private clipboard: Clipboard,
+ private userDataService: DfUserDataService,
+ private snackBar: MatSnackBar
+ ) {}
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (
+ (changes['apiDocJson'] || changes['serviceName']) &&
+ this.apiDocJson &&
+ this.serviceName
+ ) {
+ this.prepareCurlCommands();
+ }
+ }
+
+ copyCurlCommand(commandText: string) {
+ this.clipboard.copy(commandText);
+ }
+
+ private prepareCurlCommands(): void {
+ this.curlCommands = [];
+ if (!this.serviceName || !this.apiDocJson?.info?.group) {
+ return;
+ }
+
+ const endpointsInfo = healthCheckEndpointsInfo[this.apiDocJson.info.group];
+ if (endpointsInfo?.length > 0) {
+ endpointsInfo.forEach(endpointInfo => {
+ const sessionToken = this.userDataService.token || 'YOUR_SESSION_TOKEN';
+ const baseUrl = `${window.location.origin}${BASE_URL}/${this.serviceName}${endpointInfo.endpoint}`;
+ const headers = `-H 'accept: application/json' -H '${SESSION_TOKEN_HEADER}: ${sessionToken}'`;
+
+ const commandForDisplay = `curl -X 'GET' '${baseUrl}' \\\n ${headers}`;
+ const commandForCopy = `curl -X 'GET' '${baseUrl}' ${headers}`;
+
+ this.curlCommands.push({
+ title: endpointInfo.title,
+ description: endpointInfo.description,
+ textForDisplay: commandForDisplay,
+ textForCopy: commandForCopy,
+ note: this.apiDocJson.paths[endpointInfo.endpoint]?.['get']?.summary,
+ });
+ });
+ }
+ }
+
+ trackByCommand(index: number, item: CurlCommand): string {
+ return item.textForCopy;
+ }
+}
diff --git a/src/app/shared/types/files.ts b/src/app/shared/types/files.ts
index a6e25dba..ba4c0541 100644
--- a/src/app/shared/types/files.ts
+++ b/src/app/shared/types/files.ts
@@ -36,3 +36,24 @@ export interface FileType {
}
type EntityType = 'file' | 'folder';
+
+export interface ApiDocJson {
+ info: {
+ description?: string;
+ title: string;
+ version?: string;
+ group: string;
+ };
+ paths: {
+ [endpoint: string]: {
+ [method: string]: {
+ operationId: string;
+ description: string;
+ summary: string;
+ tags: string[];
+ [key: string]: any;
+ };
+ };
+ };
+ [key: string]: any;
+}
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index f8f4399c..8b00adbf 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -117,6 +117,20 @@
"python3": "Python3",
"nodejs": "Node.js"
},
+ "apiHealthBanner": {
+ "loading": "Checking API status...",
+ "healthy": "API status: Healthy",
+ "unhealthyBase": "API status: Unhealthy.",
+ "viewDetails": "View Details",
+ "hideDetails": "Hide Details",
+ "warningDefault": "Warning: This type of API currently does not support automatic health checks."
+ },
+ "apiBasicCurlCommands": {
+ "title": "Quickstart: Test Your API Connection:",
+ "quickStartDetails": "Start by testing your API with these sample curl commands. They return real data and confirm your connection is active.",
+ "copyTooltip": "Copy",
+ "nextStepFooter": {"header": "Next Step:", "body": "Scroll below to explore generated endpoints that allow you to read, write, and filter your data via REST"}
+ },
"nav": {
"error": {
"header": "Error"
diff --git a/src/dark-style.scss b/src/dark-style.scss
index f361b0a5..aa651f21 100644
--- a/src/dark-style.scss
+++ b/src/dark-style.scss
@@ -95,7 +95,8 @@ $df-purple-palette: mat.define-palette(theme.$df-purple-palette);
input,
textarea,
button,
- span {
+ span,
+ .themed-text {
color: white !important;
}
.mat-mdc-form-field-required-marker {