@brabander/nswag
v0.5.1
Published
`@brabander/nswag` (`type:adapter`, `scope:shared`) bevat de initiële NSwag-opzet voor het genereren van een TypeScript API-client uit een OpenAPI/Swagger-specificatie.
Readme
nswag
@brabander/nswag (type:adapter, scope:shared) bevat de initiële NSwag-opzet voor het genereren van een TypeScript API-client uit een OpenAPI/Swagger-specificatie.
Inhoud
nswag.json— de NSwag-configuratie: genereert een TypeScript-client met hetAxios-template, DTO's alsclasses(niet interfaces), optionele parameters en?-properties. Client-splitsing gebeurt per resource viaoperationGenerationMode: MultipleClientsFromOperationId(operationId's volgen de vormResource_Actie, bv.Products_GetAll). Gebruikt eenclientBaseClass(ApiServiceBase) en eentemplateDirectorymet custom Liquid-templates (zie hieronder) om duplicatie in de gegenereerde clients te verkleinen. De document-urlenoutputzijn$(DocumentUrl)/$(Output)-variabelen (met fallback viadefaultVariablesnaar de fixture hieronder) zodat andere apps dit ene config-bestand hergebruiken vianswag run nswag.json /variables:...in plaats van hun eigennswag.jsonte dupliceren — zie "Gebruik door consumerende apps" hieronder.swagger.json— een voorbeeld-OpenAPI 3.0-specificatie met 20 endpoints verdeeld over drie resources (Products,Customers,Orders), inclusief search/list (GETmet query-parameters),GETby id,POST,PUT,PATCHenDELETE. Dient uitsluitend als fixture om de generatie/templates van dit package te testen — dit is niet de plek voor de spec van een echte applicatie, zie "Gebruik door consumerende apps" hieronder.src/lib/api-service-base.ts— handgeschrevenApiServiceBasemethttpGet/httpPost/httpPut/httpPatch/httpDelete, gebouwd op@brabander/http-client-axios. Gegenereerde clientclasses erven hiervan i.p.v. zelf axios-request/response-boilerplate te dupliceren. Bevat ookhandleResult/toResult: één centrale plek die eenhttpX(...)-call, een verwachte succes-statuscode en optioneleparseValue/parseError-functies omzet naar eenResult<T, E | Error>(uit@brabander/result) — HTTP-foutstatussen worden naar de bijpassendeResult-variant gemapt, en transportfouten (netwerk, timeout, abort) of een gooiende parser worden opgevangen en ook alsResult.error(...)teruggegeven. De Promise die een gegenereerde methode teruggeeft reject dus nooit meer. Als er geenAxiosInstancewordt meegegeven aan de constructor, maaktApiServiceBaseer zelf één aan met defaultContent-Type/Accept: application/json-headers — zo hoeft geen enkele gegenereerde operation deze headers nog zelf te emitten (zie ook de kanttekening hieronder).templates/— custom Liquid-templates die NSwag's ingebouwdeAxiosClient.liquid/Client.RequestUrl.liquid/File.Header.liquidoverriden (viatemplateDirectoryinnswag.json). Elke operation bouwt de request-URL/headers zoals voorheen, maar roept nu in één keerthis.handleResult(this.httpX(...), successStatus, parseValue, parseError)aan opApiServiceBaseen geeftPromise<Result<T, E | Error>>terug — er wordt geen aparteprocessX-methode meer gegenereerd en er wordt niet meer gegooid bij een foutstatus. OmdatAxiosClient.liquidNSwag's ingebouwdeClient.ProcessResponse.*-templates niet meer aanroept, worden die eenvoudigweg niet meer gerenderd (geen aparte override nodig). NSwag's eigenFile.liquid/File.Utilities.liquid(niet overridden) genereren nog altijd een ongebruikteApiException-class enthrowException/isAxiosError-functies als eenmalige footer; dat is onschadelijke restcode (noUnusedLocals: falseintsconfig.lib.jsontolereert dit al) en wordt niet apart weggewerkt.templates/Class.liquidoverridet daarnaast NJsonSchema's ingebouwde DTO-template zodat gegenereerde classesBaseDto+@date/@dto/@arraygebruiken — zie de aparte sectie hieronder.templates/Client.RequestUrl.liquidbouwt querystrings op via@brabander/http-client'sQueryStringBuilder(addString/addNumber/addBoolean/addDate, hun...When-varianten,addStringArray/addNumberArray/addDateArray,addObjectArray) i.p.v. inline string-concatenatie per parameter — zie de kanttekening hieronder over het encoding-verschil met de oude aanpak.src/lib/dto/base-dto.ts+src/lib/dto/decorators/— handgeschrevenBaseDto<T>en de@date/@dto/@array-decorators die DTO-classes gebruiken om duplicatie in constructor/init/fromJS-boilerplate te verkleinen. Zie de aparte sectie hieronder.src/generated/— waarnx run nswag:generate(zonder/variables:-overrides) de fixture-client (ProductsClient,CustomersClient,OrdersClient+ DTO-classes) uitswagger.jsonneerzet. Dit is een gitignored build-artefact, niet gecommit en niet geëxporteerd viasrc/index.ts— niets in dit package of elders in de workspace gebruikt deze fixture-classes. Het dient uitsluitend als lokale smoke-test: draainx run nswag:generatena een wijziging aanswagger.jsonof de templates om te controleren dat de generatie nog slaagt en er valide TypeScript uitkomt.
Kanttekeningen bij de custom templates
ApiServiceBase's methodes heten bewusthttpGet/httpPost/httpPut/httpPatch/httpDeletei.p.v.get/post/put/patch/delete: OpenAPI-operations heten vaak letterlijk zo (bv. eenDelete- ofPatch-operation), en zo'n gegenereerde methode zou een gelijknamige base-class-methode stilzwijgend overriden i.p.v. hem aan te roepen — TypeScript ving dit gelukkig af als compile-error (TS2416) tijdens het testen van deze templates, maar dehttp-prefix voorkomt de botsing structureel.- Alleen JSON-request-bodies worden ondersteund (
operation.HasContent) — form-data/multipart/urlencoded bodies en file upload/download (IsFile) worden niet door deze fork afgehandeld;swagger.jsonheeft hier vandaag geen operations van. UseTransformOptionsMethod/UseTransformResultMethod-hooks worden niet ondersteund door deze fork; geef in plaats daarvan een geconfigureerdeAxiosInstancemee aan de constructor voor interceptor-achtige aanpassingen. StandaardContent-Type/Accept: application/json-headers zitten sinds kort op die (automatisch aangemaakte)AxiosInstancei.p.v. per-operation inAxiosClient.liquid— geef zelf eenAxiosInstancemee als je andere default-headers nodig hebt; die is dan zelf verantwoordelijk voor headers (geen automatische merge).- Querystrings worden opgebouwd via
@brabander/http-client'sQueryStringBuilder, die internURLSearchParamsgebruikt. Dat encodeert een spatie als+, terwijl de oude inlineencodeURIComponent-aanpak een spatie als%20encodeerde — een wire-format-verandering. Nog niet geverifieerd tegen een echte backend; controleer bij het aansluiten van de echte spec of+in een querystring als spatie geïnterpreteerd wordt (bv. ASP.NET Core model binding), en pas zo nodig de builder aan om spaties expliciet als%20te coderen. - Gebruik de
extensionCode-instelling niet om deApiServiceBase-import toe te voegen: in NSwag 14.7.1 lekt daarbij een fragment van een absoluut pad aan het eind van het gegenereerde bestand (geïsoleerd gereproduceerd met alléénextensionCodegezet, los vantemplateDirectory/clientBaseClass/useAbortSignal). De import wordt in plaats daarvan toegevoegd via een override vanFile.Header.liquid(die nu ook deResult-import bevat). - Documenteer wijzigingen aan de templates hier in plaats van in de
.liquid-bestanden zelf — een eerdere{% comment %}-poging werd voor de zekerheid verwijderd tijdens het isoleren van deextensionCode-bug hierboven en niet teruggezet. - Error-response pattern (Result i.p.v. throw): de nieuwe templatelogica gaat ervan uit dat elke operation hooguit één success-response en hooguit één getypeerde non-success-response heeft (vandaag altijd waar: elke operation in
swagger.jsonheeft precies één 2xx-status, en foutresponses zijn altijd400of404, getypeerd alsProblemDetails). Bij meerdere getypeerde succes- of foutresponses per operation pakt de template alleen de eerste; dat is een bewuste vereenvoudiging, geen bug — pas de template aan als de spec dit ooit nodig heeft. - Bewuste gedragswijziging: een HTTP-status die niet in de spec van die specifieke operation voorkomt, maar wel op de lijst
400/401/403/404/409staat, wordt nu een getypeerdResult-foutobject i.p.v. eenthrow. Dat is kleiner/eenvoudiger dan het oude exacte-match-of-throw-gedrag, maar wel een contractwijziging waar consumers van de client rekening mee moeten houden. - Optionele, niet-nullable query-parameters gooien niet meer bij
null: NSwag's standaardgedrag was om voor een optionele query-parameter die volgens de spec niet nullable is (bv.searchingetAll) toch eenif (x === null) throw ...-guard te genereren vóór deqs_.addString(...)-call. Overbodig, wantQueryStringBuilder.add()(aangeroepen dooraddString/addNumber/addBoolean/addDate) skiptnull/undefinedsowieso al stilzwijgend viaisDefined(@brabander/core).Client.RequestUrl.liquidgebruikt voor zulke optionele parameters nu i.p.v. de throw+plain-add de...When-variant metisDefinedals predicate (bv.qs_.addStringWhen(isDefined, "search", search)), en de bijbehorende parameter-types inAxiosClient.liquidzijn aangepast vanT | undefinednaarNullable<T>zodat de signature het (al bestaande) runtime-gedrag eerlijk weergeeft.QueryStringBuilder'saddStringWhen/addNumberWhen/addBooleanWhen/addDateWhennamen daarvoor een plainbooleanals eerste argument; dat is eenpredicate: (value) => boolean-callback geworden, zodatisDefineder direct in past.
BaseDto en mapping-decorators
Elke NSwag-gegenereerde DTO-class herhaalde vroeger dezelfde boilerplate: een constructor, een init(_data) die per property handmatig new Date(...) of X.fromJS(item) deed, een static fromJS, en een toJSON. src/lib/dto/base-dto.ts en src/lib/dto/decorators/ vervangen dat door een BaseDto-basisclass plus drie property-decorators die metadata registreren, en één generieke init() op BaseDto die die metadata gebruikt om de ruwe constructor-data te mappen:
export class Order extends BaseDto {
constructor(data?: IOrder) {
super(data);
}
id!: string;
customerId!: string;
@date() createdAt?: Date;
@array(() => OrderItem) items!: OrderItem[];
}
export type IOrder = PublicProperties<Omit<Order, 'init'>>;- Gewone properties (
id,customerId) hebben geen decorator en geendeclarenodig — gewoon een normale!/?-veld-declaratie. @date()— maakt er bij constructie daadwerkelijk eennew Date(...)van.@dto(() => Ctor)— maakt er daadwerkelijk eennew Ctor(...)van (voor een geneste DTO-class).@array(() => Ctor)— mapt elk element van een array viamapModelnaarnew Ctor(item)(of laat een element dat al eenCtor-instance is ongemoeid). Alléén bedoeld voor arrays van geneste DTO-classes, niet voorDate[]/primitieven — zie "Bekende beperkingen" hieronder.static fromJS(data)— blijft bestaan (return new ClassName(data)) omdat de (ongewijzigde)AxiosClient.liquid/Client.RequestUrl.liquid-templates dit aanroepen om response-data te parsen.I{ClassName}wordt niet meer apart door NJsonSchema gegenereerd, maar afgeleid van de class zelf viaPublicProperties<Omit<{ClassName}, 'init'>>(een utility type uitsrc/lib/dto/metadata.ts) — de publieke properties van het model zíjn het interface-type, zonder aparte generatie die uit de pas kan lopen.- Geen
toJSONmeer nodig: properties zijn gewone (enumerable) class fields, dusJSON.stringify(instance)serialiseert ze al correct — geneste Dto-instances enDate(die zelf al een ingebouwdetoJSONheeft) recursief inbegrepen.
Waarom dit werkt: experimentalDecorators + useDefineForClassFields: false
@date/@dto/@array zijn legacy TypeScript property-decorators ((target, propertyKey) => void), niet de nieuwe TC39 stage-3 decorators. Ze schrijven metadata (welke properties getransformeerd moeten worden, en hoe) op de class-prototype onder een niet-geëxporteerde Symbol, gemerged per class zodat overerving automatisch werkt. BaseDto.init(data) leest die metadata terug en past de transformatie generiek toe.
Dit vereist packages/nswag/tsconfig.lib.json/tsconfig.spec.json: "experimentalDecorators": true (voor de decorator-syntax zelf) én "useDefineForClassFields": false. Dat laatste is essentieel en niet optioneel: het root-tsconfig.base.json heeft target: "es2022", en bij useDefineForClassFields op de (impliciete) default true initialiseert élk class-field van een subclass zich opnieuw naar undefined zodra super(data) terugkeert — dat overschrijft alles wat BaseDto's init() (aangeroepen vanuit de BaseDto-constructor, dus vóór die subclass-veld-initialisatie) net heeft toegekend. Empirisch geverifieerd tijdens de implementatie: zonder useDefineForClassFields: false werden zelfs de gedecoreerde properties stilzwijgend undefined.
Bekende beperkingen:
@dto/@arrayverwachten een thunk (() => Ctor), niet de class-referentie direct — zo kan een Dto-class verwijzen naar een andere Dto-class die pas later in hetzelfde gegenereerde bestand gedeclareerd wordt, zonder een temporal-dead-zone-fout (de thunk wordt pas op constructie-tijd aangeroepen, niet bij class-declaratie).@array(Ctor)roept internnew Ctor()(zonder argumenten) aan en checkt daarnainstanceof BaseDtoom te weten of.init(...)aangeroepen moet worden — dit werkt dus alléén voor arrays van andereBaseDto-subclasses, niet voorDate[]of arrays van primitieven (die hebben ook geen decorator nodig, ze worden gewoon 1-op-1 gekopieerd).- Vitest/Vite in deze workspace (rolldown + oxc) kan legacy decorator-syntax nog niet parsen — zelfs
class Foo { @bar() x; }los van alle eigen logica geeft eenSyntaxError.tsc(de daadwerkelijke compiler, gebruikt voornx build) compileert dezelfde code wél probleemloos.vitest.config.mtsvan dit package bevat daarom een lokale Vite-plugin die dit package's.ts-bestanden eerst via@swc/core(al een workspace-devDependency) transformeert, met dezelfdeexperimentalDecorators/useDefineForClassFields-instellingen als de tsconfig's — zie de comment daar. Zodra oxc/rolldown dit zelf ondersteunt, kan die plugin weer weg.
templates/Class.liquid — scope van de override
Deze template overridet NJsonSchema's ingebouwde DTO-generatie (niet NSwag's eigen client-templates) zodat platte/datum-/array-/geneste-object-properties bovenstaande vorm krijgen. Alleen deze gevallen worden gemodelleerd; voor elke class die overerving, discriminators, dictionaries of abstracte classes gebruikt (of generateCloneMethod aanstaat), valt de template terug op NJsonSchema's originele, volledige generatie — als letterlijk ingesloten kopie van de upstream Class.liquid (NJsonSchema's eigen {% template %}-tag blijkt alleen te werken als het hele bestand vervangt, niet genest in een {% if %}, dus een echte kopie was de enige werkende optie). Een indexer-property ([key: string]: any) triggert deze fallback bewust niet — zie de code-comment in het template voor waarom. De huidige swagger.json-fixture gebruikt geen van de fallback-triggerende features, dus die tak is verified-to-parse maar niet in de praktijk doorlopen; controleer dit lokaal (regenereren + inspecteren) voordat een consumerende app een spec aanlevert die er wel gebruik van maakt.
nswag.jsonheeft"convertConstructorInterfaceData": truenodig — zonder die instelling rapporteert NJsonSchema voor elke property (ook geneste objecten/arrays)SupportsConstructorConversion: false, waardoor het template nooit@dto/@arrayzou toevoegen.
Dit alles is echt gevalideerd in een sandbox met .NET 8 SDK (via Ubuntu's eigen apt-repository, niet via het door het netwerkbeleid geblokkeerde dotnet.microsoft.com): nx run nswag:generate → nx run nswag:build → nx test nswag liepen allen succesvol door, en een los smoke-testje bevestigde dat new Product(data).createdAt en new Order(data).items[0] daadwerkelijk instanceof Date/instanceof OrderItem zijn.
Client genereren
Vereist een lokaal geïnstalleerde .NET SDK (NSwag draait als .NET-tool onder water, ook via de npm-wrapper). Installeer eerst de dependencies (@brabander/result is een nieuwe dependency van dit package) voordat je regenereert:
npm ci
nx run nswag:generate
# of rechtstreeks:
npx nswag run nswag.jsonGebruik door consumerende apps
@brabander/nswag is de gedeelde, herbruikbare basis: één nswag.json (deze, in dit package) met de Liquid-templates in templates/ en de handgeschreven runtime (ApiServiceBase, BaseDto, de @date/@dto/@array-decorators, PublicProperties). Een applicatie die een eigen backend-spec heeft, roept dit ene config-bestand rechtstreeks aan en overschrijft alleen de paden die per app verschillen — er komt geen eigen nswag.json per app bij. De gegenereerde client hoort wél in de app zelf te wonen, niet in dit package.
Dit werkt via NSwag's eigen variabelen-mechanisme: documentGenerator.fromDocument.url en codeGenerators.openApiToTypeScriptClient.output zijn in dit nswag.json $(DocumentUrl)/$(Output)-placeholders, met fallback-waarden in "defaultVariables" die dit package's eigen swagger.json-fixture genereren. Een consumerende app overschrijft ze via nswag run <pad-naar-dit-nswag.json> /variables:DocumentUrl=...,Output=....
Belangrijk: alle relatieve paden in dit nswag.json (inclusief $(DocumentUrl)/$(Output)/templateDirectory) worden door NSwag opgelost relatief aan de map van dit config-bestand (packages/nswag), ongeacht vanuit welke working directory de CLI wordt aangeroepen — dus ook een override-waarde die vanuit een andere app komt, wordt geschreven als een pad relatief aan packages/nswag. templateDirectory staat vast op "templates" (relatief aan dit package) en hoeft nooit overschreven te worden — templates leven altijd hier, ongeacht welke app genereert.
apps/school is het referentievoorbeeld:
apps/school/src/app/client/openapi.yaml— de OpenAPI-spec van de school-backend.apps/school/package.json—"generate": "nswag run ../../packages/nswag/nswag.json /variables:DocumentUrl=../../apps/school/src/app/client/openapi.yaml,Output=../../apps/school/src/app/client/generated/api-client.ts"(beide paden relatief aanpackages/nswag, zie hierboven) — zo werktnx run school:generate. Heeft ook@brabander/nswag(voorApiServiceBase/BaseDto/decorators),axios(zelfde^1.18.1-range als dit package, om npm-hoisting van axios-types consistent te houden) ennswag(de CLI) als dependency.apps/school/tsconfig.app.json/tsconfig.spec.json— hebben dezelfdeexperimentalDecorators: true/useDefineForClassFields: falsenodig als dit package (zie "Waarom dit werkt" hierboven).
Belangrijk voor herbruikbaarheid: templates/File.Header.liquid importeert ApiServiceBase/BaseDto/de decorators/PublicProperties via '@brabander/nswag' (pakket-naam), niet via relatieve paden — zo werkt dezelfde templateDirectory voor elke consumerende app, ongeacht waar de gegenereerde output terechtkomt.
Een nieuwe app die dit patroon wil volgen: voeg alleen een "generate"-script toe dat packages/nswag/nswag.json aanroept met eigen DocumentUrl/Output-variabelen (paden relatief aan packages/nswag), en zorg dat @brabander/nswag + axios (zelfde range) als dependency zijn opgenomen. Geen nieuw config-bestand nodig.
This library was generated with Nx.
Building
Run nx build nswag to build the library.
Running unit tests
Run nx test nswag to execute the unit tests via Vitest.
