447 lines
16 KiB
JavaScript
447 lines
16 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.',
|
|
}),
|
|
);
|