Files
Investidor-V2/tools/zones-openapi.mjs
T
nala 250ac35fd1
tests / ci (push) Canceled after 0s
first commit
2026-09-28 10:01:09 +02:00

40 lines
6.5 KiB
JavaScript

import fs from 'node:fs';
const routes = JSON.parse(fs.readFileSync(process.argv[2] ?? 'C:/Users/macbook/AppData/Local/Temp/zones-routes.json', 'utf8').replace(/^\uFEFF/, ''));
const string = {type: 'string'};
const uuid = {type: 'string', format: 'uuid'};
const date = {type: 'string', format: 'date'};
const version = {type: 'integer', minimum: 1};
const nullable = schema => ({...schema, nullable: true});
const object = (properties, required = []) => ({type: 'object', properties, required});
const relation = {association_type: {type: 'string', enum: ['official','influence','proximity','potential','historical']},role: {type:'string',enum:['primary','beneficiary','served','enabled','dependent','related','potential','historical']},source_reference: {type:'string',minLength:10,maxLength:2000},justification:{type:'string',minLength:10,maxLength:3000},legal_source_id:nullable(uuid),valid_from:date,valid_until:nullable(date),priority:{type:'integer',minimum:0,maximum:100},is_primary:{type:'boolean'}};
const relationRequired=['association_type','role','source_reference','justification','valid_from'];
const schemas = {
Version: object({version_no:version},['version_no']),
Zone: object({version_no:version,code:{type:'string',maxLength:80},category:{type:'string',enum:['zee','zfi','industrial_park','pending']},name:{type:'string',maxLength:255},name_en:nullable(string),description:nullable(string),description_en:nullable(string),province:nullable(string),district:nullable(string),operator_id:nullable(uuid),legal_source_id:nullable(uuid)},['code','category','name']),
LegalSource: object({title:string,url:{type:'string',format:'uri',description:'HTTPS reference only; no remote download.'},diploma:string,article:string,competent_entity:string,valid_from:date,valid_until:nullable(date)},['title','url','diploma','article','competent_entity','valid_from']),
Link: object({opportunity_id:{type:'integer',minimum:1},...relation},['opportunity_id',...relationRequired]),
Relation: object({cluster_id:nullable(uuid),corridor_id:nullable(uuid),project_id:nullable(uuid),...relation},relationRequired),
Geometry: object({geometry:object({type:{type:'string',enum:['Point','LineString','Polygon','MultiPolygon']},coordinates:{type:'array',items:{}}},['type','coordinates']),purpose:{type:'string',enum:['official_perimeter','influence','site','alternative']},legal_source_id:nullable(uuid),source_reference:string,validating_entity:string,observed_at:date},['geometry','purpose','source_reference','validating_entity','observed_at']),
};
schemas.Zone.description='PUT requires version_no. Save withdraws publication and requires a new independent review. No official status inferred from category.';
schemas.Relation.description='Exactly one of cluster_id, corridor_id, project_id. Official association requires legal_source_id. Unique zone-target pair; duplicate returns 409.';
schemas.Link.description='Official association requires legal_source_id. Creates or reuses UUID mapping for existing opportunity; duplicate zone-item pair returns 409. Potential remains potential after review.';
schemas.Geometry.description='GeoJSON geometry in EPSG:4326, 2D, valid, nonempty, max 200000 encoded bytes. Official perimeter requires Polygon/MultiPolygon and legal source. No status inferred from geometry.';
const paths={}; let operations=0;
for(const route of routes){
const path='/'+route.uri; const method=route.method.split('|')[0].toLowerCase(); const admin=path.includes('/admin/');
const parameters=[...path.matchAll(/\{([^}]+)\}/g)].map(match=>({name:match[1],in:'path',required:true,schema:match[1]==='action'?{type:'string',enum:path.includes('/admin/zones/')?['publish','suspend']:['validate','revoke']}:uuid}));
if(method==='get' && path.endsWith('/zones')) parameters.push({name:'q',in:'query',schema:{type:'string',maxLength:120}},{name:'category',in:'query',schema:{type:'string',enum:['zee','zfi','industrial_park']}},{name:'page',in:'query',schema:version});
if(path.endsWith('/zone-map')) parameters.push({name:'zone_id',in:'query',schema:uuid},{name:'bbox[]',in:'query',style:'form',explode:true,schema:{type:'array',minItems:4,maxItems:4,items:{type:'number'}},description:'west,south,east,north; no antimeridian wrap.'});
const creates=method==='post'&&!path.includes('{action}');
const operation={operationId:route.action.split('\\').at(-1).replace('@','_')+'_'+operations++,summary:route.action.split('\\').at(-1),parameters,security:admin?[{laravelSession:[]}]:[],responses:{[creates?'201':'200']:{description:'Success. JSON; paginated lists. Private management responses use Cache-Control: private, no-store.',content:{'application/json':{schema:{type:'object'}}}},'403':{description:'Missing manager/MFA/designated role or self-review.'},'404':{description:'Not found or not publicly visible.'},'409':{description:'Stale version or duplicate relation.'},'419':{description:'Session/CSRF validation failed.'},'422':{description:'Invalid data, source, geometry, overlap or workflow.'},'429':{description:'Rate limit exceeded.'}}};
if(method!=='get'){
const schema=path.includes('{action}')?'Version':path.endsWith('/legal-sources')?'LegalSource':path.endsWith('/links')?'Link':path.endsWith('/relations')?'Relation':path.endsWith('/geometries')?'Geometry':'Zone';
operation.requestBody={required:true,content:{'application/json':{schema:{$ref:'#/components/schemas/'+schema}}}};
}
(paths[path]??={})[method]=operation;
}
const doc={openapi:'3.0.3',info:{title:'InvestMoz zone foundation API',version:'2026-09-28',description:'Implemented local routes only. Session cookies and CSRF; no bearer protocol invented. Admin: verified manager with confirmed MFA; legal/GIS/publish actions also require configured designated role. 30 requests/minute for management; public API uses application throttle. Approval in the portal is documentary review, not an administrative act. API v1 remains compatible and adds optional zee_id/zfi_id/park_id filters. Response schemas are deliberately open pending full contract typing.'},paths,components:{securitySchemes:{laravelSession:{type:'apiKey',in:'cookie',name:'laravel_session',description:'Actual session cookie name is configured by Laravel. Send X-XSRF-TOKEN from the session; no credentials embedded.'}},schemas}};
fs.writeFileSync('docs/investmoz/zones/openapi.json',JSON.stringify(doc,null,2)+'\n');
console.log(JSON.stringify({documented_operations:operations,registered_routes:routes.length,scope:'Route/method coverage; not exhaustive response-schema conformance.'}));