@@ -31,7 +31,7 @@ import {
3131 getPublishTier ,
3232 getResourceDescriptorKey ,
3333} from '../lib/resource-path.js' ;
34- import { isAutoGeneratedId } from '../lib/auto-generated.js' ;
34+ import { isAutoGeneratedId , isPortalGeneratedSchemaId } from '../lib/auto-generated.js' ;
3535import { resolveWorkspaceFilter , shouldIncludeResource } from './filter-service.js' ;
3636
3737/**
@@ -277,6 +277,7 @@ export async function planApiPublication(
277277
278278 let importSpecification = false ;
279279 let importSpecificationFormat : string | undefined ;
280+ let importSpecSchemaComponents : Record < string , unknown > | undefined ;
280281 let operationDescriptionPuts : ResourceDescriptor [ ] = [ ] ;
281282 if ( specificationAllowed ) {
282283 const specification = await store . readContent ( config . sourceDir , apiDescriptor , 'specification' ) ;
@@ -288,6 +289,10 @@ export async function planApiPublication(
288289 getImportFormat ( specification . format ?? 'yaml' , apiType , dialect ) !== undefined ;
289290 importSpecificationFormat = importSpecification ? specification . format : undefined ;
290291 if ( importSpecification ) {
292+ importSpecSchemaComponents = getSpecComponentSchemas (
293+ specification . content ,
294+ specification . format
295+ ) ;
291296 operationDescriptionPuts = getOpenApiOperationIdsWithNullDescription (
292297 specification . content ,
293298 specification . format
@@ -316,13 +321,52 @@ export async function planApiPublication(
316321 }
317322
318323 if ( importSpecification ) {
319- const explicitSchemas = allDescriptors . filter (
324+ const candidateSchemas = allDescriptors . filter (
320325 ( descriptor ) =>
321326 descriptor . type === ResourceType . ApiSchema &&
322327 getNamePart ( descriptor . nameParts , 0 ) . toLowerCase ( ) === apiName . toLowerCase ( ) &&
323328 descriptor . workspace === apiDescriptor . workspace &&
324329 ! isAutoGeneratedId ( getNamePart ( descriptor . nameParts , 1 ) )
325330 ) ;
331+ // Portal-created schemas (13-digit Date.now() names) duplicate on re-PUT
332+ // because the spec import recreates their content (#274). A 13-digit name
333+ // alone is a weak signal, so only skip when the imported spec provably
334+ // recreates every component the artifact schema defines — same name AND
335+ // structurally identical definition. Any ambiguity → publish the schema
336+ // like any explicitly named one.
337+ const explicitSchemas = (
338+ await Promise . all (
339+ candidateSchemas . map ( async ( descriptor ) => {
340+ if ( ! isPortalGeneratedSchemaId ( getNamePart ( descriptor . nameParts , 1 ) ) ) {
341+ return descriptor ;
342+ }
343+ const specComponents = importSpecSchemaComponents ;
344+ if ( ! specComponents || Object . keys ( specComponents ) . length === 0 ) {
345+ return descriptor ;
346+ }
347+ let schemaJson : Record < string , unknown > | undefined ;
348+ try {
349+ schemaJson = await store . readResource ( config . sourceDir , descriptor ) ;
350+ } catch {
351+ return descriptor ;
352+ }
353+ const artifactComponents = getArtifactSchemaComponents ( schemaJson ) ;
354+ if ( ! artifactComponents ) {
355+ return descriptor ;
356+ }
357+ const entries = Object . entries ( artifactComponents ) ;
358+ if ( entries . length === 0 ) {
359+ return descriptor ;
360+ }
361+ const recreatedByImport = entries . every (
362+ ( [ name , definition ] ) =>
363+ Object . hasOwn ( specComponents , name ) &&
364+ deepEqualUnordered ( specComponents [ name ] , definition )
365+ ) ;
366+ return recreatedByImport ? undefined : descriptor ;
367+ } )
368+ )
369+ ) . filter ( ( descriptor ) : descriptor is ResourceDescriptor => descriptor !== undefined ) ;
326370 // WSDL-import-generated XSD schemas have GUID names (not 24-hex), so the
327371 // isAutoGeneratedId filter misses them. APIM recreates them on WSDL import
328372 // and rejects re-PUTs, so exclude schemas with the XSD content type — but
@@ -1069,6 +1113,86 @@ function detectSpecDialect(content: string, format: string | undefined): ApiSpec
10691113 }
10701114}
10711115
1116+ /**
1117+ * Extracts the schema component definitions declared in an OpenAPI/Swagger
1118+ * spec document: `components.schemas` (OpenAPI 3.x) or `definitions`
1119+ * (Swagger 2.0). Returns undefined when the content is not a parseable
1120+ * OpenAPI/Swagger document (e.g. WSDL/GraphQL) — callers must treat that as
1121+ * "no evidence" rather than "no schemas".
1122+ */
1123+ function getSpecComponentSchemas (
1124+ content : string ,
1125+ format : string | undefined
1126+ ) : Record < string , unknown > | undefined {
1127+ if ( format !== undefined && format !== 'yaml' && format !== 'json' ) {
1128+ return undefined ;
1129+ }
1130+ try {
1131+ const doc = yaml . load ( content ) as Record < string , unknown > | undefined ;
1132+ if ( ! doc || typeof doc !== 'object' ) return undefined ;
1133+ const components = ( doc . components as Record < string , unknown > | undefined ) ?. schemas
1134+ ?? doc . definitions ;
1135+ if ( components && typeof components === 'object' && ! Array . isArray ( components ) ) {
1136+ return components as Record < string , unknown > ;
1137+ }
1138+ return { } ;
1139+ } catch {
1140+ return undefined ;
1141+ }
1142+ }
1143+
1144+ /**
1145+ * Extracts the schema component definitions from an ApiSchema artifact's
1146+ * `properties.document` (`components.schemas` or Swagger `definitions`).
1147+ * Only OpenAPI/Swagger content types are inspected: in a standalone JSON
1148+ * Schema document (`schemaType: json`) `definitions` has a different meaning
1149+ * and the spec import does not recreate such a resource, so returns
1150+ * undefined ("no evidence") for any other content type.
1151+ */
1152+ function getArtifactSchemaComponents (
1153+ json : Record < string , unknown > | null | undefined
1154+ ) : Record < string , unknown > | undefined {
1155+ const props = json ?. properties as Record < string , unknown > | undefined ;
1156+ const doc = props ?. document as Record < string , unknown > | undefined ;
1157+ if ( ! doc || typeof doc !== 'object' ) return undefined ;
1158+ const contentType = ( props ?. contentType as string | undefined ) ?. toLowerCase ( ) ?? '' ;
1159+ if (
1160+ ! contentType . includes ( 'openapi.components' ) &&
1161+ ! contentType . includes ( 'swagger.definitions' )
1162+ ) {
1163+ return undefined ;
1164+ }
1165+ const components = ( doc . components as Record < string , unknown > | undefined ) ?. schemas
1166+ ?? doc . definitions ;
1167+ if ( components && typeof components === 'object' && ! Array . isArray ( components ) ) {
1168+ return components as Record < string , unknown > ;
1169+ }
1170+ return undefined ;
1171+ }
1172+
1173+ /**
1174+ * Deep structural equality with order-insensitive object keys (array order
1175+ * still matters — e.g. `required` lists are order-preserving in serialized
1176+ * specs but semantically it is safer to demand exact array equality).
1177+ */
1178+ function deepEqualUnordered ( a : unknown , b : unknown ) : boolean {
1179+ if ( a === b ) return true ;
1180+ if ( Array . isArray ( a ) || Array . isArray ( b ) ) {
1181+ if ( ! Array . isArray ( a ) || ! Array . isArray ( b ) || a . length !== b . length ) return false ;
1182+ return a . every ( ( item , i ) => deepEqualUnordered ( item , b [ i ] ) ) ;
1183+ }
1184+ if ( a && b && typeof a === 'object' && typeof b === 'object' ) {
1185+ const aObj = a as Record < string , unknown > ;
1186+ const bObj = b as Record < string , unknown > ;
1187+ const aKeys = Object . keys ( aObj ) ;
1188+ if ( aKeys . length !== Object . keys ( bObj ) . length ) return false ;
1189+ return aKeys . every (
1190+ ( key ) => Object . hasOwn ( bObj , key ) && deepEqualUnordered ( aObj [ key ] , bObj [ key ] )
1191+ ) ;
1192+ }
1193+ return false ;
1194+ }
1195+
10721196/**
10731197 * Sanitize an OpenAPI spec before importing into APIM.
10741198 * Currently handles:
0 commit comments