1+ import { Injectable } from "@angular/core" ;
2+
3+ export const EXPORT_FORMATS = [ "postman" , "openapi" , "insomnia" , "bruno" , "har" , "curl" ] as const ;
4+ export type ExportFormat = ( typeof EXPORT_FORMATS ) [ number ] ;
5+ export type ExportChange = "NEW" | "UPDATE" | "UNCHANGED" ;
6+ export type ExportDiagnosticSeverity = "info" | "warning" | "error" ;
7+
8+ export interface ExportCapabilities {
9+ readonly format : ExportFormat ;
10+ readonly label : string ;
11+ readonly supported : boolean ;
12+ readonly lossy : boolean ;
13+ readonly counts : Readonly < Record < string , number > > ;
14+ readonly note : string ;
15+ }
16+
17+ export interface ExportDiagnostic {
18+ readonly code : string ;
19+ readonly severity : ExportDiagnosticSeverity ;
20+ readonly operationIds : readonly string [ ] ;
21+ readonly message : string ;
22+ readonly suggestion : string ;
23+ }
24+
25+ export interface ExportFilePreview {
26+ readonly path : string ;
27+ readonly change : ExportChange ;
28+ readonly overwriteRisk : boolean ;
29+ readonly outsideWorkspace : boolean ;
30+ readonly content : string ;
31+ }
32+
33+ export interface ExportDryRun {
34+ readonly files : readonly ExportFilePreview [ ] ;
35+ readonly diagnostics : readonly ExportDiagnostic [ ] ;
36+ readonly canGenerate : boolean ;
37+ readonly requiresOverwriteConfirmation : boolean ;
38+ readonly requiresOutsideWorkspaceConfirmation : boolean ;
39+ }
40+
41+ export interface ExportRequest {
42+ readonly formats : readonly ExportFormat [ ] ;
43+ readonly outputDirectory : string ;
44+ readonly workspaceRoot : string ;
45+ readonly endpointCount : number ;
46+ readonly operations ?: readonly ExportOperation [ ] ;
47+ readonly existingFiles ?: readonly ExportExistingFile [ ] ;
48+ readonly overwriteConfirmed ?: boolean ;
49+ readonly outsideWorkspaceConfirmed ?: boolean ;
50+ }
51+
52+ export interface ExportOperation {
53+ readonly id : string ;
54+ readonly method : string ;
55+ readonly path : string ;
56+ readonly description ?: string ;
57+ readonly diagnostics ?: readonly ExportDiagnostic [ ] ;
58+ }
59+
60+ export interface ExportExistingFile {
61+ readonly path : string ;
62+ readonly content : string ;
63+ }
64+
65+ export interface ExportResult {
66+ readonly files : readonly ExportFilePreview [ ] ;
67+ readonly outputDirectory : string ;
68+ readonly postmanInstalled : boolean ;
69+ }
70+
71+ export interface ExportArtifactWriter {
72+ write ( path : string , content : string ) : Promise < void > ;
73+ }
74+
75+ export class MemoryExportArtifactWriter implements ExportArtifactWriter {
76+ readonly written = new Map < string , string > ( ) ;
77+
78+ async write ( path : string , content : string ) : Promise < void > {
79+ this . written . set ( path , content ) ;
80+ }
81+ }
82+
83+ const FORMAT_DETAILS : Readonly < Record < ExportFormat , Omit < ExportCapabilities , "format" > > > = {
84+ postman : { label : "Postman" , supported : true , lossy : false , counts : { collections : 1 , operations : 0 } , note : "Collection and environment compatible with Postman." } ,
85+ openapi : { label : "OpenAPI 3.1" , supported : true , lossy : false , counts : { documents : 1 , operations : 0 } , note : "Canonical HTTP contract with schemas and responses." } ,
86+ insomnia : { label : "Insomnia v4" , supported : true , lossy : true , counts : { exports : 1 , operations : 0 } , note : "HTTP operations preserved; non-HTTP transports may be partial." } ,
87+ bruno : { label : "Bruno" , supported : true , lossy : true , counts : { collections : 1 , operations : 0 } , note : "Folder collection; generated requests preserve HTTP details." } ,
88+ har : { label : "HAR" , supported : true , lossy : true , counts : { archives : 1 , operations : 0 } , note : "Network archive; auth metadata can be lossy." } ,
89+ curl : { label : "cURL" , supported : true , lossy : true , counts : { scripts : 1 , operations : 0 } , note : "One shell command per HTTP operation." } ,
90+ } ;
91+
92+ @Injectable ( { providedIn : "root" } )
93+ export class ExportsClient {
94+ constructor ( private readonly artifactWriter : ExportArtifactWriter = new MemoryExportArtifactWriter ( ) ) { }
95+
96+ capabilities ( endpointCount : number ) : readonly ExportCapabilities [ ] {
97+ return EXPORT_FORMATS . map ( ( format ) => ( { format, ...FORMAT_DETAILS [ format ] , counts : { ...FORMAT_DETAILS [ format ] . counts , operations : endpointCount } } ) ) ;
98+ }
99+
100+ dryRun ( request : ExportRequest ) : ExportDryRun {
101+ const operations = request . operations ?? createOperations ( request . endpointCount ) ;
102+ const files = request . formats . map ( ( format ) => {
103+ const path = joinPath ( request . outputDirectory , `tanit-${ format } .${ format === "curl" ? "sh" : "json" } ` ) ;
104+ const content = renderArtifact ( format , operations ) ;
105+ const existing = request . existingFiles ?. find ( ( file ) => normalizePath ( file . path ) === normalizePath ( path ) ) ;
106+ const outsideWorkspace = ! isContainedPath ( request . workspaceRoot , request . outputDirectory ) ;
107+ const change : ExportChange = existing === undefined ? "NEW" : existing . content === content ? "UNCHANGED" : "UPDATE" ;
108+ return { path, change, overwriteRisk : change === "UPDATE" , outsideWorkspace, content } ;
109+ } ) ;
110+ const diagnostics : ExportDiagnostic [ ] = request . endpointCount === 0 ? [ {
111+ code : "NO_ENDPOINTS" ,
112+ severity : "error" ,
113+ operationIds : [ ] ,
114+ message : "No endpoints are available for export." ,
115+ suggestion : "Scan the project or adjust the endpoint filters before generating." ,
116+ } ] : operations . flatMap ( ( operation ) => operation . diagnostics ?? [ ] ) ;
117+ const requiresOverwriteConfirmation = files . some ( ( file ) => file . overwriteRisk ) && request . overwriteConfirmed !== true ;
118+ const requiresOutsideWorkspaceConfirmation = files . some ( ( file ) => file . outsideWorkspace ) && request . outsideWorkspaceConfirmed !== true ;
119+ return { files, diagnostics, canGenerate : diagnostics . every ( ( item ) => item . severity !== "error" ) && ! requiresOverwriteConfirmation && ! requiresOutsideWorkspaceConfirmation , requiresOverwriteConfirmation, requiresOutsideWorkspaceConfirmation } ;
120+ }
121+
122+ generate ( request : ExportRequest ) : Promise < ExportResult > {
123+ const preview = this . dryRun ( request ) ;
124+ if ( ! preview . canGenerate ) return Promise . reject ( new Error ( "Export confirmation is required before generating." ) ) ;
125+ return Promise . all ( preview . files . map ( ( file ) => this . artifactWriter . write ( file . path , file . content ) ) ) . then ( ( ) => ( { files : preview . files , outputDirectory : request . outputDirectory , postmanInstalled : false } ) ) ;
126+ }
127+ }
128+
129+ function createOperations ( endpointCount : number ) : readonly ExportOperation [ ] {
130+ return Array . from ( { length : Math . max ( 0 , endpointCount ) } , ( _ , index ) => ( { id : `operation-${ index + 1 } ` , method : "GET" , path : `/operation-${ index + 1 } ` } ) ) ;
131+ }
132+
133+ function normalizePath ( value : string ) : string {
134+ const segments : string [ ] = [ ] ;
135+ for ( const segment of value . replaceAll ( "\\" , "/" ) . split ( "/" ) ) {
136+ if ( ! segment || segment === "." ) continue ;
137+ if ( segment === ".." ) segments . pop ( ) ; else segments . push ( segment ) ;
138+ }
139+ return `/${ segments . join ( "/" ) } ` ;
140+ }
141+
142+ function joinPath ( directory : string , filename : string ) : string {
143+ return `${ normalizePath ( directory ) } /${ filename } ` ;
144+ }
145+
146+ function isContainedPath ( workspaceRoot : string , candidate : string ) : boolean {
147+ const root = normalizePath ( workspaceRoot ) ;
148+ const path = normalizePath ( candidate ) ;
149+ return path === root || path . startsWith ( `${ root } /` ) ;
150+ }
151+
152+ function renderArtifact ( format : ExportFormat , operations : readonly ExportOperation [ ] ) : string {
153+ if ( format === "curl" ) return operations . map ( ( operation ) => `curl -X ${ operation . method } "http://localhost${ operation . path } "` ) . join ( "\n" ) ;
154+ if ( format === "har" ) return JSON . stringify ( { log : { version : "1.2" , entries : operations . map ( ( operation ) => ( { request : { method : operation . method , url : `http://localhost${ operation . path } ` } } ) ) } } , null , 2 ) ;
155+ if ( format === "openapi" ) return JSON . stringify ( { openapi : "3.1.0" , info : { title : "Tanit export" , version : "1.0.0" } , paths : Object . fromEntries ( operations . map ( ( operation ) => [ operation . path , { [ operation . method . toLowerCase ( ) ] : { operationId : operation . id , responses : { "200" : { description : "Success" } } } } ] ) ) } , null , 2 ) ;
156+ if ( format === "postman" ) return JSON . stringify ( { info : { name : "Tanit export" , schema : "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" } , item : operations . map ( ( operation ) => ( { name : operation . id , request : { method : operation . method , url : `http://localhost${ operation . path } ` } } ) ) } , null , 2 ) ;
157+ if ( format === "insomnia" ) return JSON . stringify ( { _type : "export" , __export_format : 4 , resources : operations . map ( ( operation ) => ( { _type : "request" , name : operation . id , method : operation . method , url : `http://localhost${ operation . path } ` } ) ) } , null , 2 ) ;
158+ return JSON . stringify ( { version : "1" , name : "Tanit export" , requests : operations . map ( ( operation ) => ( { name : operation . id , request : { method : operation . method , url : `http://localhost${ operation . path } ` } } ) ) } , null , 2 ) ;
159+ }
0 commit comments