48 lines
9.0 KiB
JavaScript
48 lines
9.0 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']},physical_category:{type:'string',enum:['economic_zone','industrial_park','agro_industrial_park','industrial_logistics_park','industrial_free_zone','integrated_tourism_area','other','unknown']},legal_regime:{type:'string',enum:['zee','zfi','general','pending','not_applicable']},development_status:{type:'string',enum:['announced','planned','in_development','partially_operational','operational','suspended','unknown']},availability_status:{type:'string',enum:['confirmed','unavailable','reported_unverified','not_informed']},area_components:{type:'array',items:{type:'object'}},source_evidence:{type:'array',items:{type:'object'}},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('zone-attachments')?['review','select-public','withdraw-public']:path.includes('/admin/zones/')?['publish','suspend']:['validate','revoke']}:uuid}));
|
|
if(method==='get' && (path.endsWith('/zones') || path.endsWith('/zones/export.csv'))) parameters.push({name:'q',in:'query',schema:{type:'string',maxLength:120}},{name:'category',in:'query',schema:{type:'string',enum:['zee','zfi','industrial_park']}},{name:'physical_category',in:'query',schema:string},{name:'legal_regime',in:'query',schema:string},{name:'development_status',in:'query',schema:string},{name:'availability_status',in:'query',schema:string},{name:'activity',in:'query',schema:string},{name:'province_id',in:'query',schema:uuid},{name:'district_id',in:'query',schema:uuid},{name:'admin_post_id',in:'query',schema:uuid},{name:'locality_id',in:'query',schema:uuid},{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 privateRoute=admin||path.includes('/management/');
|
|
const operation={operationId:route.action.split('\\').at(-1).replace('@','_')+'_'+operations++,summary:route.action.split('\\').at(-1),parameters,security:privateRoute?[{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(path.endsWith('/zones/export.csv')) operation.responses['200']={description:'CSV filtered with the same server-side criteria as the public zone list.',content:{'text/csv':{schema:{type:'string',format:'binary'}}}};
|
|
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';
|
|
if(path.endsWith('/attachments')){
|
|
operation.requestBody={required:true,content:{'multipart/form-data':{schema:object({file:{type:'string',format:'binary'},kind:{type:'string',enum:['legal_instrument','map','photo','study','other']},caption:{type:'string',maxLength:255},source_reference:{type:'string',minLength:5,maxLength:2000},source_date:nullable(date),rights_status:{type:'string',enum:['pending','confirmed','restricted']},rights_basis:nullable(string)},['file','kind','caption','source_reference','rights_status'])}}};
|
|
} else if(path.includes('zone-attachments/{id}/{action}')){
|
|
operation.requestBody={required:true,content:{'application/json':{schema:object({version_no:version,decision:{type:'string',enum:['reviewed','rejected']},rights_status:{type:'string',enum:['pending','confirmed','restricted']},rights_basis:nullable(string),personal_data_status:{type:'string',enum:['none','present','unknown']}},path.endsWith('/review')?['version_no','decision','rights_status','personal_data_status']:['version_no'])}}};
|
|
} else {
|
|
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.'}));
|