From f7279d53bcb07e28417cbafd3853ed2dd8c4b5da Mon Sep 17 00:00:00 2001 From: Pierre Villard Date: Tue, 23 Dec 2025 14:49:54 +0100 Subject: [PATCH 1/2] NIFI-15196 - Improve Content Viewer resolution based on MIME Type --- .../StandardContentViewerController.java | 24 +-- .../contentviewer/ContentTypeResolver.java | 172 ++++++++++++++++++ .../org/apache/nifi/web/api/FlowResource.java | 109 +++++++++-- .../feature/content-viewer.component.ts | 75 ++++---- .../service/content-viewer.service.ts | 6 + 5 files changed, 320 insertions(+), 66 deletions(-) create mode 100644 nifi-framework-bundle/nifi-framework/nifi-web/nifi-ui-extension/src/main/java/org/apache/nifi/ui/extension/contentviewer/ContentTypeResolver.java diff --git a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java index f249aeb4a9fb..70b82e9bd781 100644 --- a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java +++ b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java @@ -84,15 +84,10 @@ public void doGet(final HttpServletRequest request, final HttpServletResponse re return; } - // allow the user to drive the data type but fall back to the content type if necessary - String displayName = request.getParameter("mimeTypeDisplayName"); + // mimeTypeDisplayName is required and should be resolved by the frontend via the /flow/content-viewers/resolve API + final String displayName = request.getParameter("mimeTypeDisplayName"); if (displayName == null) { - final String contentType = downloadableContent.getType(); - displayName = getDisplayName(contentType); - } - - if (displayName == null) { - response.sendError(HttpURLConnection.HTTP_BAD_REQUEST, "Unknown content type"); + response.sendError(HttpURLConnection.HTTP_BAD_REQUEST, "mimeTypeDisplayName parameter is required"); return; } @@ -185,17 +180,4 @@ public void doGet(final HttpServletRequest request, final HttpServletResponse re response.sendError(HttpURLConnection.HTTP_INTERNAL_ERROR, "Unable to format FlowFile content"); } } - - private String getDisplayName(final String contentType) { - return switch (contentType) { - case "application/json" -> "json"; - case "application/xml", "text/xml" -> "xml"; - case "application/avro-binary", "avro/binary", "application/avro+binary" -> "avro"; - case "text/x-yaml", "text/yaml", "text/yml", "application/x-yaml", "application/x-yml", "application/yaml", - "application/yml" -> "yaml"; - case "text/plain" -> "text"; - case "text/csv" -> "csv"; - case null, default -> null; - }; - } } diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-ui-extension/src/main/java/org/apache/nifi/ui/extension/contentviewer/ContentTypeResolver.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-ui-extension/src/main/java/org/apache/nifi/ui/extension/contentviewer/ContentTypeResolver.java new file mode 100644 index 000000000000..64bf2d6153a2 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-ui-extension/src/main/java/org/apache/nifi/ui/extension/contentviewer/ContentTypeResolver.java @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.nifi.ui.extension.contentviewer; + +import java.util.Collection; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Utility class for resolving content types to their appropriate display names and content viewers. + * Supports both exact MIME type matching and pattern-based matching for structured suffix types + * like application/fhir+xml, application/vnd.api+json, etc. + */ +public final class ContentTypeResolver { + + // Patterns for matching structured syntax suffixes (RFC 6839) + private static final Pattern XML_SUFFIX_PATTERN = Pattern.compile("application/[a-zA-Z0-9.-]+\\+xml"); + private static final Pattern JSON_SUFFIX_PATTERN = Pattern.compile("application/[a-zA-Z0-9.-]+\\+json"); + private static final Pattern YAML_SUFFIX_PATTERN = Pattern.compile("application/[a-zA-Z0-9.-]+\\+yaml"); + + private ContentTypeResolver() { + // Utility class + } + + /** + * Resolves a content type to its display name by checking against the supported MIME types + * of the provided content viewers. First attempts exact matching, then falls back to + * pattern-based matching for structured suffix types. + * + * @param contentType the content type to resolve (e.g., "application/fhir+xml") + * @param contentViewers the collection of available content viewers + * @return an Optional containing the matching SupportedMimeTypes, or empty if no match found + */ + public static Optional resolve(final String contentType, final Collection contentViewers) { + if (contentType == null || contentViewers == null) { + return Optional.empty(); + } + + // First, try exact matching + for (final ContentViewer viewer : contentViewers) { + for (final SupportedMimeTypes supportedMimeTypes : viewer.getSupportedMimeTypes()) { + for (final String mimeType : supportedMimeTypes.getMimeTypes()) { + if (contentType.equals(mimeType)) { + return Optional.of(new ResolvedContentType(viewer, supportedMimeTypes)); + } + } + } + } + + // Then, try startsWith matching (for types like "text/plain; charset=UTF-8") + for (final ContentViewer viewer : contentViewers) { + for (final SupportedMimeTypes supportedMimeTypes : viewer.getSupportedMimeTypes()) { + for (final String mimeType : supportedMimeTypes.getMimeTypes()) { + if (contentType.startsWith(mimeType)) { + return Optional.of(new ResolvedContentType(viewer, supportedMimeTypes)); + } + } + } + } + + // Finally, try pattern-based matching for structured suffix types + final String suffixDisplayName = resolveSuffixPattern(contentType); + if (suffixDisplayName != null) { + for (final ContentViewer viewer : contentViewers) { + for (final SupportedMimeTypes supportedMimeTypes : viewer.getSupportedMimeTypes()) { + if (suffixDisplayName.equals(supportedMimeTypes.getDisplayName())) { + return Optional.of(new ResolvedContentType(viewer, supportedMimeTypes)); + } + } + } + } + + return Optional.empty(); + } + + /** + * Resolves a content type to its display name without requiring the viewer collection. + * First checks exact matches, then falls back to pattern-based matching for structured suffix types. + * + * @param contentType the content type to resolve + * @return the display name (xml, json, yaml, etc.) if found, null otherwise + */ + public static String resolveDisplayName(final String contentType) { + if (contentType == null) { + return null; + } + + // First check exact matches + final String exactMatch = switch (contentType) { + case "application/json" -> "json"; + case "application/xml", "text/xml" -> "xml"; + case "application/avro-binary", "avro/binary", "application/avro+binary" -> "avro"; + case "text/x-yaml", "text/yaml", "text/yml", "application/x-yaml", "application/x-yml", "application/yaml", + "application/yml" -> "yaml"; + case "text/plain" -> "text"; + case "text/csv" -> "csv"; + default -> null; + }; + + if (exactMatch != null) { + return exactMatch; + } + + // Fall back to pattern-based matching for structured suffix types (RFC 6839) + return resolveSuffixPattern(contentType); + } + + /** + * Resolves a content type to its display name using pattern-based matching + * for structured suffix types (RFC 6839). + * + * @param contentType the content type to resolve + * @return the display name (xml, json, yaml) if pattern matches, null otherwise + */ + public static String resolveSuffixPattern(final String contentType) { + if (contentType == null) { + return null; + } + + if (XML_SUFFIX_PATTERN.matcher(contentType).matches()) { + return "xml"; + } + if (JSON_SUFFIX_PATTERN.matcher(contentType).matches()) { + return "json"; + } + if (YAML_SUFFIX_PATTERN.matcher(contentType).matches()) { + return "yaml"; + } + + return null; + } + + /** + * Represents a resolved content type with its associated viewer and supported MIME types. + */ + public static class ResolvedContentType { + private final ContentViewer contentViewer; + private final SupportedMimeTypes supportedMimeTypes; + + public ResolvedContentType(final ContentViewer contentViewer, final SupportedMimeTypes supportedMimeTypes) { + this.contentViewer = contentViewer; + this.supportedMimeTypes = supportedMimeTypes; + } + + public ContentViewer getContentViewer() { + return contentViewer; + } + + public SupportedMimeTypes getSupportedMimeTypes() { + return supportedMimeTypes; + } + + public String getDisplayName() { + return supportedMimeTypes.getDisplayName(); + } + } +} + diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java index d1406f2bfb40..bbbd34cac3ff 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java @@ -76,7 +76,9 @@ import org.apache.nifi.groups.ProcessGroup; import org.apache.nifi.nar.NarClassLoadersHolder; import org.apache.nifi.registry.flow.FlowVersionLocation; +import org.apache.nifi.ui.extension.contentviewer.ContentTypeResolver; import org.apache.nifi.ui.extension.contentviewer.ContentViewer; +import org.apache.nifi.ui.extension.contentviewer.SupportedMimeTypes; import org.apache.nifi.web.IllegalClusterResourceRequestException; import org.apache.nifi.web.NiFiServiceFacade; import org.apache.nifi.web.ResourceNotFoundException; @@ -191,6 +193,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; @@ -373,25 +376,14 @@ public Response getContentViewers(@Context final HttpServletRequest httpServletR final List dtos = new ArrayList<>(); contentViewers.forEach((contentViewer) -> { - final String contextPath = contentViewer.getContextPath(); - final BundleCoordinate bundleCoordinate = contentViewer.getBundle().getBundleDetails().getCoordinate(); - - final String displayName = StringUtils.substringBefore(contextPath.substring(1), "-" + bundleCoordinate.getVersion()); - - final ContentViewerDTO dto = new ContentViewerDTO(); - dto.setDisplayName(displayName + " " + bundleCoordinate.getVersion()); - - final List supportedMimeTypes = contentViewer.getSupportedMimeTypes().stream().map((supportedMimeType -> { + final List supportedMimeTypesDTOs = contentViewer.getSupportedMimeTypes().stream().map((supportedMimeType -> { final SupportedMimeTypesDTO mimeTypesDto = new SupportedMimeTypesDTO(); mimeTypesDto.setDisplayName(supportedMimeType.getDisplayName()); mimeTypesDto.setMimeTypes(supportedMimeType.getMimeTypes()); return mimeTypesDto; })).collect(Collectors.toList()); - dto.setSupportedMimeTypes(supportedMimeTypes); - - final URI contentViewerUri = RequestUriBuilder.fromHttpServletRequest(httpServletRequest).path(contextPath).build(); - dto.setUri(contentViewerUri.toString()); + final ContentViewerDTO dto = createContentViewerDTO(contentViewer, supportedMimeTypesDTOs, httpServletRequest); dtos.add(dto); }); @@ -401,6 +393,97 @@ public Response getContentViewers(@Context final HttpServletRequest httpServletR return generateOkResponse(entity).build(); } + /** + * Resolves a MIME type to the appropriate content viewer. + * + * @param mimeType the MIME type to resolve + * @return A contentViewerDTO for the matching viewer, or null if no match found. + */ + @GET + @Consumes(MediaType.WILDCARD) + @Produces(MediaType.APPLICATION_JSON) + @Path("content-viewers/resolve") + @Operation( + summary = "Resolves a MIME type to the appropriate content viewer", + description = "Finds the content viewer that supports the given MIME type, using both exact matching and pattern-based matching for structured suffix types (e.g., application/fhir+xml).", + responses = { + @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ContentViewerDTO.class))), + @ApiResponse(responseCode = "400", description = "NiFi was unable to complete the request because it was invalid. The request should not be retried without modification."), + @ApiResponse(responseCode = "401", description = "Client could not be authenticated."), + @ApiResponse(responseCode = "403", description = "Client is not authorized to make this request."), + @ApiResponse(responseCode = "404", description = "No content viewer found for the specified MIME type."), + @ApiResponse(responseCode = "409", description = "The request was valid but NiFi was not in the appropriate state to process it.") + }, + security = { + @SecurityRequirement(name = "Read - /flow") + } + ) + public Response resolveContentViewer( + @Parameter(description = "The MIME type to resolve to a content viewer", required = true) + @QueryParam("mimeType") final String mimeType, + @Context final HttpServletRequest httpServletRequest) { + + if (StringUtils.isBlank(mimeType)) { + return Response.status(Response.Status.BAD_REQUEST) + .entity("The mimeType query parameter is required") + .build(); + } + + authorizeFlow(); + + @SuppressWarnings("unchecked") + final Collection contentViewers = (Collection) servletContext.getAttribute("content-viewers"); + + final Optional resolvedContentType = ContentTypeResolver.resolve(mimeType, contentViewers); + + if (resolvedContentType.isEmpty()) { + return Response.status(Response.Status.NOT_FOUND) + .entity("No content viewer found for MIME type: " + mimeType) + .build(); + } + + final ContentViewer contentViewer = resolvedContentType.get().getContentViewer(); + final SupportedMimeTypes supportedMimeTypes = resolvedContentType.get().getSupportedMimeTypes(); + + final SupportedMimeTypesDTO mimeTypesDto = new SupportedMimeTypesDTO(); + mimeTypesDto.setDisplayName(supportedMimeTypes.getDisplayName()); + mimeTypesDto.setMimeTypes(supportedMimeTypes.getMimeTypes()); + + final ContentViewerDTO dto = createContentViewerDTO(contentViewer, List.of(mimeTypesDto), httpServletRequest); + + return generateOkResponse(dto).build(); + } + + /** + * Creates a ContentViewerDTO from a ContentViewer. + * + * @param contentViewer the content viewer + * @param supportedMimeTypes the supported MIME types DTOs + * @param httpServletRequest the HTTP request for building URIs + * @return a populated ContentViewerDTO + */ + private ContentViewerDTO createContentViewerDTO( + final ContentViewer contentViewer, + final List supportedMimeTypes, + final HttpServletRequest httpServletRequest) { + + final String contextPath = contentViewer.getContextPath(); + final BundleCoordinate bundleCoordinate = contentViewer.getBundle().getBundleDetails().getCoordinate(); + + // Extract display name from context path by removing the leading "/" and the version suffix + // e.g., "/nifi-standard-content-viewer-2.8.0-SNAPSHOT" -> "nifi-standard-content-viewer" + final String viewerName = StringUtils.substringBefore(contextPath.substring(1), "-" + bundleCoordinate.getVersion()); + + final ContentViewerDTO dto = new ContentViewerDTO(); + dto.setDisplayName(viewerName + " " + bundleCoordinate.getVersion()); + dto.setSupportedMimeTypes(supportedMimeTypes); + + final URI contentViewerUri = RequestUriBuilder.fromHttpServletRequest(httpServletRequest).path(contextPath).build(); + dto.setUri(contentViewerUri.toString()); + + return dto; + } + /** * Retrieves the identity of the user making the request. * diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts index e56fdbbd2d1c..87f49da6829a 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts @@ -29,9 +29,10 @@ import { navigateToBundledContentViewer, resetContent, setRef } from '../state/c import { MatSelectChange } from '@angular/material/select'; import { loadAbout } from '../../../state/about/about.actions'; import { selectAbout } from '../../../state/about/about.selectors'; -import { filter, map, switchMap, take } from 'rxjs'; +import { catchError, filter, map, of, switchMap, take } from 'rxjs'; import { navigateToExternalViewer } from '../state/external-viewer/external-viewer.actions'; import { snackBarError } from '../../../state/error/error.actions'; +import { ContentViewerService } from '../service/content-viewer.service'; interface SupportedContentViewer { supportedMimeTypes: SupportedMimeTypes; @@ -49,6 +50,7 @@ export class ContentViewerComponent implements OnInit, OnDestroy { private store = inject>(Store); private nifiCommon = inject(NiFiCommon); private formBuilder = inject(FormBuilder); + private contentViewerService = inject(ContentViewerService); viewerForm: FormGroup; viewAsOptions: SelectGroup[] = []; @@ -224,22 +226,36 @@ export class ContentViewerComponent implements OnInit, OnDestroy { private handleDefaultSelection(): void { if (!this.viewerSelected) { if (this.mimeType) { - const compatibleViewerOption = this.getCompatibleViewer(this.mimeType); - if (compatibleViewerOption === null) { - if (this.defaultSupportedMimeTypeId !== null) { - this.viewerForm.get('viewAs')?.setValue(String(this.defaultSupportedMimeTypeId)); - this.loadContentViewer(this.defaultSupportedMimeTypeId); - } + // Call backend API to resolve the MIME type to the appropriate content viewer + this.contentViewerService + .resolveContentViewer(this.mimeType) + .pipe( + take(1), + catchError(() => of(null)) + ) + .subscribe((resolvedViewer) => { + if (resolvedViewer) { + // Find the matching viewer in the local lookup by URI and displayName + const compatibleViewerOption = this.findViewerByResolution(resolvedViewer); + if (compatibleViewerOption !== null) { + this.viewerForm.get('viewAs')?.setValue(String(compatibleViewerOption)); + this.loadContentViewer(compatibleViewerOption); + return; + } + } - this.store.dispatch( - snackBarError({ - error: `No compatible content viewer found for mime type [${this.mimeType}]` - }) - ); - } else { - this.viewerForm.get('viewAs')?.setValue(String(compatibleViewerOption)); - this.loadContentViewer(compatibleViewerOption); - } + // Fallback to default hex viewer if no match found + if (this.defaultSupportedMimeTypeId !== null) { + this.viewerForm.get('viewAs')?.setValue(String(this.defaultSupportedMimeTypeId)); + this.loadContentViewer(this.defaultSupportedMimeTypeId); + } + + this.store.dispatch( + snackBarError({ + error: `No compatible content viewer found for mime type [${this.mimeType}]` + }) + ); + }); } else if (this.defaultSupportedMimeTypeId !== null) { this.viewerForm.get('viewAs')?.setValue(String(this.defaultSupportedMimeTypeId)); this.loadContentViewer(this.defaultSupportedMimeTypeId); @@ -247,22 +263,17 @@ export class ContentViewerComponent implements OnInit, OnDestroy { } } - private getCompatibleViewer(mimeType: string): number | null { - for (const group of this.viewAsOptions) { - for (const option of group.options) { - const supportedMimeTypeId: number = Number(option.value); - if (Number.isInteger(supportedMimeTypeId)) { - const supportedContentViewer = this.supportedContentViewerLookup.get(supportedMimeTypeId); - if (supportedContentViewer) { - const supportsMimeType = supportedContentViewer.supportedMimeTypes.mimeTypes.some( - (supportedMimeType) => mimeType.startsWith(supportedMimeType) - ); - - if (supportsMimeType) { - return supportedMimeTypeId; - } - } - } + private findViewerByResolution(resolvedViewer: any): number | null { + const resolvedUri = resolvedViewer.uri; + const resolvedDisplayName = resolvedViewer.supportedMimeTypes?.[0]?.displayName; + + for (const [supportedMimeTypeId, supportedContentViewer] of this.supportedContentViewerLookup.entries()) { + // Match by URI and display name + if ( + supportedContentViewer.contentViewer.uri === resolvedUri && + supportedContentViewer.supportedMimeTypes.displayName === resolvedDisplayName + ) { + return supportedMimeTypeId; } } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/service/content-viewer.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/service/content-viewer.service.ts index d60f3d565cf6..b6c9aa454be7 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/service/content-viewer.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/service/content-viewer.service.ts @@ -29,6 +29,12 @@ export class ContentViewerService { return this.httpClient.get(`${ContentViewerService.API}/flow/content-viewers`); } + resolveContentViewer(mimeType: string): Observable { + return this.httpClient.get(`${ContentViewerService.API}/flow/content-viewers/resolve`, { + params: { mimeType } + }); + } + getBlob(url: string, offset: number, length: number): Observable { const headers = new HttpHeaders({ Range: `bytes=${offset}-${length}` From 4fde64188b67fa4440f3746acc4b672ba765b6b1 Mon Sep 17 00:00:00 2001 From: Pierre Villard Date: Tue, 23 Dec 2025 16:28:33 +0100 Subject: [PATCH 2/2] simple approach --- .../StandardContentViewerController.java | 24 ++- .../main/webapp/META-INF/nifi-content-viewer | 6 +- .../contentviewer/ContentTypeResolver.java | 172 ------------------ .../org/apache/nifi/web/api/FlowResource.java | 109 ++--------- .../feature/content-viewer.component.ts | 103 ++++++----- .../service/content-viewer.service.ts | 6 - 6 files changed, 97 insertions(+), 323 deletions(-) delete mode 100644 nifi-framework-bundle/nifi-framework/nifi-web/nifi-ui-extension/src/main/java/org/apache/nifi/ui/extension/contentviewer/ContentTypeResolver.java diff --git a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java index 70b82e9bd781..f249aeb4a9fb 100644 --- a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java +++ b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/java/org/apache/nifi/web/controller/StandardContentViewerController.java @@ -84,10 +84,15 @@ public void doGet(final HttpServletRequest request, final HttpServletResponse re return; } - // mimeTypeDisplayName is required and should be resolved by the frontend via the /flow/content-viewers/resolve API - final String displayName = request.getParameter("mimeTypeDisplayName"); + // allow the user to drive the data type but fall back to the content type if necessary + String displayName = request.getParameter("mimeTypeDisplayName"); if (displayName == null) { - response.sendError(HttpURLConnection.HTTP_BAD_REQUEST, "mimeTypeDisplayName parameter is required"); + final String contentType = downloadableContent.getType(); + displayName = getDisplayName(contentType); + } + + if (displayName == null) { + response.sendError(HttpURLConnection.HTTP_BAD_REQUEST, "Unknown content type"); return; } @@ -180,4 +185,17 @@ public void doGet(final HttpServletRequest request, final HttpServletResponse re response.sendError(HttpURLConnection.HTTP_INTERNAL_ERROR, "Unable to format FlowFile content"); } } + + private String getDisplayName(final String contentType) { + return switch (contentType) { + case "application/json" -> "json"; + case "application/xml", "text/xml" -> "xml"; + case "application/avro-binary", "avro/binary", "application/avro+binary" -> "avro"; + case "text/x-yaml", "text/yaml", "text/yml", "application/x-yaml", "application/x-yml", "application/yaml", + "application/yml" -> "yaml"; + case "text/plain" -> "text"; + case "text/csv" -> "csv"; + case null, default -> null; + }; + } } diff --git a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/webapp/META-INF/nifi-content-viewer b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/webapp/META-INF/nifi-content-viewer index 94b1c6398602..106a71c4cebd 100644 --- a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/webapp/META-INF/nifi-content-viewer +++ b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-content-viewer/src/main/webapp/META-INF/nifi-content-viewer @@ -12,9 +12,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -xml=application/xml,text/xml -json=application/json +xml=application/xml,text/xml,application/*+xml +json=application/json,application/*+json text=text/plain csv=text/csv avro=avro/binary,application/avro-binary,application/avro+binary -yaml=text/x-yaml,text/yaml,text/yml,application/x-yaml,application/x-yml,application/yaml,application/yml \ No newline at end of file +yaml=text/x-yaml,text/yaml,text/yml,application/x-yaml,application/x-yml,application/yaml,application/yml,application/*+yaml \ No newline at end of file diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-ui-extension/src/main/java/org/apache/nifi/ui/extension/contentviewer/ContentTypeResolver.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-ui-extension/src/main/java/org/apache/nifi/ui/extension/contentviewer/ContentTypeResolver.java deleted file mode 100644 index 64bf2d6153a2..000000000000 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-ui-extension/src/main/java/org/apache/nifi/ui/extension/contentviewer/ContentTypeResolver.java +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.nifi.ui.extension.contentviewer; - -import java.util.Collection; -import java.util.Optional; -import java.util.regex.Pattern; - -/** - * Utility class for resolving content types to their appropriate display names and content viewers. - * Supports both exact MIME type matching and pattern-based matching for structured suffix types - * like application/fhir+xml, application/vnd.api+json, etc. - */ -public final class ContentTypeResolver { - - // Patterns for matching structured syntax suffixes (RFC 6839) - private static final Pattern XML_SUFFIX_PATTERN = Pattern.compile("application/[a-zA-Z0-9.-]+\\+xml"); - private static final Pattern JSON_SUFFIX_PATTERN = Pattern.compile("application/[a-zA-Z0-9.-]+\\+json"); - private static final Pattern YAML_SUFFIX_PATTERN = Pattern.compile("application/[a-zA-Z0-9.-]+\\+yaml"); - - private ContentTypeResolver() { - // Utility class - } - - /** - * Resolves a content type to its display name by checking against the supported MIME types - * of the provided content viewers. First attempts exact matching, then falls back to - * pattern-based matching for structured suffix types. - * - * @param contentType the content type to resolve (e.g., "application/fhir+xml") - * @param contentViewers the collection of available content viewers - * @return an Optional containing the matching SupportedMimeTypes, or empty if no match found - */ - public static Optional resolve(final String contentType, final Collection contentViewers) { - if (contentType == null || contentViewers == null) { - return Optional.empty(); - } - - // First, try exact matching - for (final ContentViewer viewer : contentViewers) { - for (final SupportedMimeTypes supportedMimeTypes : viewer.getSupportedMimeTypes()) { - for (final String mimeType : supportedMimeTypes.getMimeTypes()) { - if (contentType.equals(mimeType)) { - return Optional.of(new ResolvedContentType(viewer, supportedMimeTypes)); - } - } - } - } - - // Then, try startsWith matching (for types like "text/plain; charset=UTF-8") - for (final ContentViewer viewer : contentViewers) { - for (final SupportedMimeTypes supportedMimeTypes : viewer.getSupportedMimeTypes()) { - for (final String mimeType : supportedMimeTypes.getMimeTypes()) { - if (contentType.startsWith(mimeType)) { - return Optional.of(new ResolvedContentType(viewer, supportedMimeTypes)); - } - } - } - } - - // Finally, try pattern-based matching for structured suffix types - final String suffixDisplayName = resolveSuffixPattern(contentType); - if (suffixDisplayName != null) { - for (final ContentViewer viewer : contentViewers) { - for (final SupportedMimeTypes supportedMimeTypes : viewer.getSupportedMimeTypes()) { - if (suffixDisplayName.equals(supportedMimeTypes.getDisplayName())) { - return Optional.of(new ResolvedContentType(viewer, supportedMimeTypes)); - } - } - } - } - - return Optional.empty(); - } - - /** - * Resolves a content type to its display name without requiring the viewer collection. - * First checks exact matches, then falls back to pattern-based matching for structured suffix types. - * - * @param contentType the content type to resolve - * @return the display name (xml, json, yaml, etc.) if found, null otherwise - */ - public static String resolveDisplayName(final String contentType) { - if (contentType == null) { - return null; - } - - // First check exact matches - final String exactMatch = switch (contentType) { - case "application/json" -> "json"; - case "application/xml", "text/xml" -> "xml"; - case "application/avro-binary", "avro/binary", "application/avro+binary" -> "avro"; - case "text/x-yaml", "text/yaml", "text/yml", "application/x-yaml", "application/x-yml", "application/yaml", - "application/yml" -> "yaml"; - case "text/plain" -> "text"; - case "text/csv" -> "csv"; - default -> null; - }; - - if (exactMatch != null) { - return exactMatch; - } - - // Fall back to pattern-based matching for structured suffix types (RFC 6839) - return resolveSuffixPattern(contentType); - } - - /** - * Resolves a content type to its display name using pattern-based matching - * for structured suffix types (RFC 6839). - * - * @param contentType the content type to resolve - * @return the display name (xml, json, yaml) if pattern matches, null otherwise - */ - public static String resolveSuffixPattern(final String contentType) { - if (contentType == null) { - return null; - } - - if (XML_SUFFIX_PATTERN.matcher(contentType).matches()) { - return "xml"; - } - if (JSON_SUFFIX_PATTERN.matcher(contentType).matches()) { - return "json"; - } - if (YAML_SUFFIX_PATTERN.matcher(contentType).matches()) { - return "yaml"; - } - - return null; - } - - /** - * Represents a resolved content type with its associated viewer and supported MIME types. - */ - public static class ResolvedContentType { - private final ContentViewer contentViewer; - private final SupportedMimeTypes supportedMimeTypes; - - public ResolvedContentType(final ContentViewer contentViewer, final SupportedMimeTypes supportedMimeTypes) { - this.contentViewer = contentViewer; - this.supportedMimeTypes = supportedMimeTypes; - } - - public ContentViewer getContentViewer() { - return contentViewer; - } - - public SupportedMimeTypes getSupportedMimeTypes() { - return supportedMimeTypes; - } - - public String getDisplayName() { - return supportedMimeTypes.getDisplayName(); - } - } -} - diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java index bbbd34cac3ff..d1406f2bfb40 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/FlowResource.java @@ -76,9 +76,7 @@ import org.apache.nifi.groups.ProcessGroup; import org.apache.nifi.nar.NarClassLoadersHolder; import org.apache.nifi.registry.flow.FlowVersionLocation; -import org.apache.nifi.ui.extension.contentviewer.ContentTypeResolver; import org.apache.nifi.ui.extension.contentviewer.ContentViewer; -import org.apache.nifi.ui.extension.contentviewer.SupportedMimeTypes; import org.apache.nifi.web.IllegalClusterResourceRequestException; import org.apache.nifi.web.NiFiServiceFacade; import org.apache.nifi.web.ResourceNotFoundException; @@ -193,7 +191,6 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; @@ -376,14 +373,25 @@ public Response getContentViewers(@Context final HttpServletRequest httpServletR final List dtos = new ArrayList<>(); contentViewers.forEach((contentViewer) -> { - final List supportedMimeTypesDTOs = contentViewer.getSupportedMimeTypes().stream().map((supportedMimeType -> { + final String contextPath = contentViewer.getContextPath(); + final BundleCoordinate bundleCoordinate = contentViewer.getBundle().getBundleDetails().getCoordinate(); + + final String displayName = StringUtils.substringBefore(contextPath.substring(1), "-" + bundleCoordinate.getVersion()); + + final ContentViewerDTO dto = new ContentViewerDTO(); + dto.setDisplayName(displayName + " " + bundleCoordinate.getVersion()); + + final List supportedMimeTypes = contentViewer.getSupportedMimeTypes().stream().map((supportedMimeType -> { final SupportedMimeTypesDTO mimeTypesDto = new SupportedMimeTypesDTO(); mimeTypesDto.setDisplayName(supportedMimeType.getDisplayName()); mimeTypesDto.setMimeTypes(supportedMimeType.getMimeTypes()); return mimeTypesDto; })).collect(Collectors.toList()); + dto.setSupportedMimeTypes(supportedMimeTypes); + + final URI contentViewerUri = RequestUriBuilder.fromHttpServletRequest(httpServletRequest).path(contextPath).build(); + dto.setUri(contentViewerUri.toString()); - final ContentViewerDTO dto = createContentViewerDTO(contentViewer, supportedMimeTypesDTOs, httpServletRequest); dtos.add(dto); }); @@ -393,97 +401,6 @@ public Response getContentViewers(@Context final HttpServletRequest httpServletR return generateOkResponse(entity).build(); } - /** - * Resolves a MIME type to the appropriate content viewer. - * - * @param mimeType the MIME type to resolve - * @return A contentViewerDTO for the matching viewer, or null if no match found. - */ - @GET - @Consumes(MediaType.WILDCARD) - @Produces(MediaType.APPLICATION_JSON) - @Path("content-viewers/resolve") - @Operation( - summary = "Resolves a MIME type to the appropriate content viewer", - description = "Finds the content viewer that supports the given MIME type, using both exact matching and pattern-based matching for structured suffix types (e.g., application/fhir+xml).", - responses = { - @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ContentViewerDTO.class))), - @ApiResponse(responseCode = "400", description = "NiFi was unable to complete the request because it was invalid. The request should not be retried without modification."), - @ApiResponse(responseCode = "401", description = "Client could not be authenticated."), - @ApiResponse(responseCode = "403", description = "Client is not authorized to make this request."), - @ApiResponse(responseCode = "404", description = "No content viewer found for the specified MIME type."), - @ApiResponse(responseCode = "409", description = "The request was valid but NiFi was not in the appropriate state to process it.") - }, - security = { - @SecurityRequirement(name = "Read - /flow") - } - ) - public Response resolveContentViewer( - @Parameter(description = "The MIME type to resolve to a content viewer", required = true) - @QueryParam("mimeType") final String mimeType, - @Context final HttpServletRequest httpServletRequest) { - - if (StringUtils.isBlank(mimeType)) { - return Response.status(Response.Status.BAD_REQUEST) - .entity("The mimeType query parameter is required") - .build(); - } - - authorizeFlow(); - - @SuppressWarnings("unchecked") - final Collection contentViewers = (Collection) servletContext.getAttribute("content-viewers"); - - final Optional resolvedContentType = ContentTypeResolver.resolve(mimeType, contentViewers); - - if (resolvedContentType.isEmpty()) { - return Response.status(Response.Status.NOT_FOUND) - .entity("No content viewer found for MIME type: " + mimeType) - .build(); - } - - final ContentViewer contentViewer = resolvedContentType.get().getContentViewer(); - final SupportedMimeTypes supportedMimeTypes = resolvedContentType.get().getSupportedMimeTypes(); - - final SupportedMimeTypesDTO mimeTypesDto = new SupportedMimeTypesDTO(); - mimeTypesDto.setDisplayName(supportedMimeTypes.getDisplayName()); - mimeTypesDto.setMimeTypes(supportedMimeTypes.getMimeTypes()); - - final ContentViewerDTO dto = createContentViewerDTO(contentViewer, List.of(mimeTypesDto), httpServletRequest); - - return generateOkResponse(dto).build(); - } - - /** - * Creates a ContentViewerDTO from a ContentViewer. - * - * @param contentViewer the content viewer - * @param supportedMimeTypes the supported MIME types DTOs - * @param httpServletRequest the HTTP request for building URIs - * @return a populated ContentViewerDTO - */ - private ContentViewerDTO createContentViewerDTO( - final ContentViewer contentViewer, - final List supportedMimeTypes, - final HttpServletRequest httpServletRequest) { - - final String contextPath = contentViewer.getContextPath(); - final BundleCoordinate bundleCoordinate = contentViewer.getBundle().getBundleDetails().getCoordinate(); - - // Extract display name from context path by removing the leading "/" and the version suffix - // e.g., "/nifi-standard-content-viewer-2.8.0-SNAPSHOT" -> "nifi-standard-content-viewer" - final String viewerName = StringUtils.substringBefore(contextPath.substring(1), "-" + bundleCoordinate.getVersion()); - - final ContentViewerDTO dto = new ContentViewerDTO(); - dto.setDisplayName(viewerName + " " + bundleCoordinate.getVersion()); - dto.setSupportedMimeTypes(supportedMimeTypes); - - final URI contentViewerUri = RequestUriBuilder.fromHttpServletRequest(httpServletRequest).path(contextPath).build(); - dto.setUri(contentViewerUri.toString()); - - return dto; - } - /** * Retrieves the identity of the user making the request. * diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts index 87f49da6829a..7d7984f5b405 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/feature/content-viewer.component.ts @@ -29,10 +29,9 @@ import { navigateToBundledContentViewer, resetContent, setRef } from '../state/c import { MatSelectChange } from '@angular/material/select'; import { loadAbout } from '../../../state/about/about.actions'; import { selectAbout } from '../../../state/about/about.selectors'; -import { catchError, filter, map, of, switchMap, take } from 'rxjs'; +import { filter, map, switchMap, take } from 'rxjs'; import { navigateToExternalViewer } from '../state/external-viewer/external-viewer.actions'; import { snackBarError } from '../../../state/error/error.actions'; -import { ContentViewerService } from '../service/content-viewer.service'; interface SupportedContentViewer { supportedMimeTypes: SupportedMimeTypes; @@ -50,7 +49,6 @@ export class ContentViewerComponent implements OnInit, OnDestroy { private store = inject>(Store); private nifiCommon = inject(NiFiCommon); private formBuilder = inject(FormBuilder); - private contentViewerService = inject(ContentViewerService); viewerForm: FormGroup; viewAsOptions: SelectGroup[] = []; @@ -226,36 +224,22 @@ export class ContentViewerComponent implements OnInit, OnDestroy { private handleDefaultSelection(): void { if (!this.viewerSelected) { if (this.mimeType) { - // Call backend API to resolve the MIME type to the appropriate content viewer - this.contentViewerService - .resolveContentViewer(this.mimeType) - .pipe( - take(1), - catchError(() => of(null)) - ) - .subscribe((resolvedViewer) => { - if (resolvedViewer) { - // Find the matching viewer in the local lookup by URI and displayName - const compatibleViewerOption = this.findViewerByResolution(resolvedViewer); - if (compatibleViewerOption !== null) { - this.viewerForm.get('viewAs')?.setValue(String(compatibleViewerOption)); - this.loadContentViewer(compatibleViewerOption); - return; - } - } - - // Fallback to default hex viewer if no match found - if (this.defaultSupportedMimeTypeId !== null) { - this.viewerForm.get('viewAs')?.setValue(String(this.defaultSupportedMimeTypeId)); - this.loadContentViewer(this.defaultSupportedMimeTypeId); - } + const compatibleViewerOption = this.getCompatibleViewer(this.mimeType); + if (compatibleViewerOption === null) { + if (this.defaultSupportedMimeTypeId !== null) { + this.viewerForm.get('viewAs')?.setValue(String(this.defaultSupportedMimeTypeId)); + this.loadContentViewer(this.defaultSupportedMimeTypeId); + } - this.store.dispatch( - snackBarError({ - error: `No compatible content viewer found for mime type [${this.mimeType}]` - }) - ); - }); + this.store.dispatch( + snackBarError({ + error: `No compatible content viewer found for mime type [${this.mimeType}]` + }) + ); + } else { + this.viewerForm.get('viewAs')?.setValue(String(compatibleViewerOption)); + this.loadContentViewer(compatibleViewerOption); + } } else if (this.defaultSupportedMimeTypeId !== null) { this.viewerForm.get('viewAs')?.setValue(String(this.defaultSupportedMimeTypeId)); this.loadContentViewer(this.defaultSupportedMimeTypeId); @@ -263,23 +247,56 @@ export class ContentViewerComponent implements OnInit, OnDestroy { } } - private findViewerByResolution(resolvedViewer: any): number | null { - const resolvedUri = resolvedViewer.uri; - const resolvedDisplayName = resolvedViewer.supportedMimeTypes?.[0]?.displayName; - - for (const [supportedMimeTypeId, supportedContentViewer] of this.supportedContentViewerLookup.entries()) { - // Match by URI and display name - if ( - supportedContentViewer.contentViewer.uri === resolvedUri && - supportedContentViewer.supportedMimeTypes.displayName === resolvedDisplayName - ) { - return supportedMimeTypeId; + private getCompatibleViewer(mimeType: string): number | null { + for (const group of this.viewAsOptions) { + for (const option of group.options) { + const supportedMimeTypeId: number = Number(option.value); + if (Number.isInteger(supportedMimeTypeId)) { + const supportedContentViewer = this.supportedContentViewerLookup.get(supportedMimeTypeId); + if (supportedContentViewer) { + const supportsMimeType = supportedContentViewer.supportedMimeTypes.mimeTypes.some( + (supportedMimeType) => this.isMediaTypeCompatible(mimeType, supportedMimeType) + ); + + if (supportsMimeType) { + return supportedMimeTypeId; + } + } + } } } return null; } + /** + * Checks if a MIME type is compatible with a supported media type pattern. + * Supports patterns like "application/*+xml" which match "application/fhir+xml". + */ + private isMediaTypeCompatible(mimeType: string, supportedMimeType: string): boolean { + // Check for exact match or startsWith match + if (mimeType === supportedMimeType || mimeType.startsWith(supportedMimeType)) { + return true; + } + + // Check for wildcard patterns like "application/*+xml" + if (supportedMimeType.includes('/*+')) { + // Parse pattern: "application/*+xml" -> type="application", suffix="+xml" + const patternMatch = supportedMimeType.match(/^([^/]+)\/\*(\+.+)$/); + if (patternMatch) { + const [, patternType, patternSuffix] = patternMatch; + // Parse mimeType: "application/fhir+xml" -> type="application", suffix="+xml" + const mimeMatch = mimeType.match(/^([^/]+)\/[^+]+(\+.+)$/); + if (mimeMatch) { + const [, mimeTypeType, mimeTypeSuffix] = mimeMatch; + return patternType === mimeTypeType && patternSuffix === mimeTypeSuffix; + } + } + } + + return false; + } + viewAsChanged(event: MatSelectChange): void { this.loadContentViewer(Number(event.value)); } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/service/content-viewer.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/service/content-viewer.service.ts index b6c9aa454be7..d60f3d565cf6 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/service/content-viewer.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/content-viewer/service/content-viewer.service.ts @@ -29,12 +29,6 @@ export class ContentViewerService { return this.httpClient.get(`${ContentViewerService.API}/flow/content-viewers`); } - resolveContentViewer(mimeType: string): Observable { - return this.httpClient.get(`${ContentViewerService.API}/flow/content-viewers/resolve`, { - params: { mimeType } - }); - } - getBlob(url: string, offset: number, length: number): Observable { const headers = new HttpHeaders({ Range: `bytes=${offset}-${length}`