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 }} + +

+
+
{{ healthError }}
+
+
+
+

+ {{ '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 }} + + + +
+

+ {{ '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 {