mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(resilience): add service proto and stub handlers (#2657)
* feat(resilience): add service proto and stub handlers Add the worldmonitor.resilience.v1 proto package, generated client/server artifacts, edge routing, and zero-state handler stubs so the domain is deployable before the seed and scoring layers land. Validation: - PATH="/Users/lucaspassos/go/bin:/Users/lucaspassos/.codex/tmp/arg0/codex-arg06nbVvG:/Users/lucaspassos/.antigravity/antigravity/bin:/Users/lucaspassos/.local/bin:/Users/lucaspassos/.codeium/windsurf/bin:/Users/lucaspassos/Library/Python/3.12/bin:/Library/Frameworks/Python.framework/Versions/3.12/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pkg/env/active/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/opt/homebrew/bin:/Applications/Codex.app/Contents/Resources" make generate - PATH="/Users/lucaspassos/go/bin:/Users/lucaspassos/.codex/tmp/arg0/codex-arg06nbVvG:/Users/lucaspassos/.antigravity/antigravity/bin:/Users/lucaspassos/.local/bin:/Users/lucaspassos/.codeium/windsurf/bin:/Users/lucaspassos/Library/Python/3.12/bin:/Library/Frameworks/Python.framework/Versions/3.12/bin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pkg/env/active/bin:/opt/pmk/env/global/bin:/Library/Apple/usr/bin:/opt/homebrew/bin:/Applications/Codex.app/Contents/Resources" npx tsx --test tests/route-cache-tier.test.mjs tests/edge-functions.test.mjs - npm run typecheck (fails on upstream Dodo/Clerk baseline) - npm run typecheck:api (fails on upstream vitest baseline) - npm run test:data (fails on upstream dodopayments-checkout baseline via tests/runtime-config-panel-visibility.test.mjs) * fix(resilience): add countryCode validation to get-resilience-score Throw ValidationError when countryCode is missing instead of silently returning a zero-state response with an empty string country code. * fix(resilience): validate countryCode format and mark required in spec - Trim whitespace and reject non-ISO-3166-1 alpha-2 codes to prevent cache pollution from malformed aliases (e.g. 'USA', ' us ', 'foobar') - Add required: true to proto QueryConfig so generated OpenAPI spec matches runtime validation behavior - Regenerated OpenAPI artifacts via make generate --------- Co-authored-by: Elie Habib <elie.habib@gmail.com>
This commit is contained in:
9
api/resilience/v1/[rpc].ts
Normal file
9
api/resilience/v1/[rpc].ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const config = { runtime: 'edge' };
|
||||
|
||||
import { createDomainGateway, serverOptions } from '../../../server/gateway';
|
||||
import { createResilienceServiceRoutes } from '../../../src/generated/server/worldmonitor/resilience/v1/service_server';
|
||||
import { resilienceHandler } from '../../../server/worldmonitor/resilience/v1/handler';
|
||||
|
||||
export default createDomainGateway(
|
||||
createResilienceServiceRoutes(resilienceHandler, serverOptions),
|
||||
);
|
||||
1
docs/api/ResilienceService.openapi.json
Normal file
1
docs/api/ResilienceService.openapi.json
Normal file
@@ -0,0 +1 @@
|
||||
{"components":{"schemas":{"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GetResilienceRankingRequest":{"type":"object"},"GetResilienceRankingResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ResilienceRankingItem"},"type":"array"}},"type":"object"},"GetResilienceScoreRequest":{"properties":{"countryCode":{"type":"string"}},"type":"object"},"GetResilienceScoreResponse":{"properties":{"change30d":{"format":"double","type":"number"},"countryCode":{"type":"string"},"cronbachAlpha":{"format":"double","type":"number"},"domains":{"items":{"$ref":"#/components/schemas/ResilienceDomain"},"type":"array"},"level":{"type":"string"},"lowConfidence":{"type":"boolean"},"overallScore":{"format":"double","type":"number"},"trend":{"type":"string"}},"type":"object"},"ResilienceDimension":{"properties":{"coverage":{"format":"double","type":"number"},"id":{"type":"string"},"score":{"format":"double","type":"number"}},"type":"object"},"ResilienceDomain":{"properties":{"dimensions":{"items":{"$ref":"#/components/schemas/ResilienceDimension"},"type":"array"},"id":{"type":"string"},"score":{"format":"double","type":"number"},"weight":{"format":"double","type":"number"}},"type":"object"},"ResilienceRankingItem":{"properties":{"countryCode":{"type":"string"},"level":{"type":"string"},"lowConfidence":{"type":"boolean"},"overallScore":{"format":"double","type":"number"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ResilienceService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/resilience/v1/get-resilience-ranking":{"get":{"operationId":"GetResilienceRanking","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetResilienceRankingResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetResilienceRanking","tags":["ResilienceService"]}},"/api/resilience/v1/get-resilience-score":{"get":{"operationId":"GetResilienceScore","parameters":[{"in":"query","name":"countryCode","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetResilienceScoreResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetResilienceScore","tags":["ResilienceService"]}}}}
|
||||
170
docs/api/ResilienceService.openapi.yaml
Normal file
170
docs/api/ResilienceService.openapi.yaml
Normal file
@@ -0,0 +1,170 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: ResilienceService API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/resilience/v1/get-resilience-score:
|
||||
get:
|
||||
tags:
|
||||
- ResilienceService
|
||||
summary: GetResilienceScore
|
||||
operationId: GetResilienceScore
|
||||
parameters:
|
||||
- name: countryCode
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetResilienceScoreResponse'
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
default:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/api/resilience/v1/get-resilience-ranking:
|
||||
get:
|
||||
tags:
|
||||
- ResilienceService
|
||||
summary: GetResilienceRanking
|
||||
operationId: GetResilienceRanking
|
||||
responses:
|
||||
"200":
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/GetResilienceRankingResponse'
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
default:
|
||||
description: Error response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
components:
|
||||
schemas:
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
description: Error message (e.g., 'user not found', 'database connection failed')
|
||||
description: Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.
|
||||
FieldViolation:
|
||||
type: object
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')
|
||||
description:
|
||||
type: string
|
||||
description: Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')
|
||||
required:
|
||||
- field
|
||||
- description
|
||||
description: FieldViolation describes a single validation error for a specific field.
|
||||
ValidationError:
|
||||
type: object
|
||||
properties:
|
||||
violations:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/FieldViolation'
|
||||
description: List of validation violations
|
||||
required:
|
||||
- violations
|
||||
description: ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.
|
||||
GetResilienceScoreRequest:
|
||||
type: object
|
||||
properties:
|
||||
countryCode:
|
||||
type: string
|
||||
GetResilienceScoreResponse:
|
||||
type: object
|
||||
properties:
|
||||
countryCode:
|
||||
type: string
|
||||
overallScore:
|
||||
type: number
|
||||
format: double
|
||||
level:
|
||||
type: string
|
||||
domains:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ResilienceDomain'
|
||||
cronbachAlpha:
|
||||
type: number
|
||||
format: double
|
||||
trend:
|
||||
type: string
|
||||
change30d:
|
||||
type: number
|
||||
format: double
|
||||
lowConfidence:
|
||||
type: boolean
|
||||
ResilienceDomain:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
score:
|
||||
type: number
|
||||
format: double
|
||||
weight:
|
||||
type: number
|
||||
format: double
|
||||
dimensions:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ResilienceDimension'
|
||||
ResilienceDimension:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
score:
|
||||
type: number
|
||||
format: double
|
||||
coverage:
|
||||
type: number
|
||||
format: double
|
||||
GetResilienceRankingRequest:
|
||||
type: object
|
||||
GetResilienceRankingResponse:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ResilienceRankingItem'
|
||||
ResilienceRankingItem:
|
||||
type: object
|
||||
properties:
|
||||
countryCode:
|
||||
type: string
|
||||
overallScore:
|
||||
type: number
|
||||
format: double
|
||||
level:
|
||||
type: string
|
||||
lowConfidence:
|
||||
type: boolean
|
||||
@@ -0,0 +1,11 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.resilience.v1;
|
||||
|
||||
import "worldmonitor/resilience/v1/resilience.proto";
|
||||
|
||||
message GetResilienceRankingRequest {}
|
||||
|
||||
message GetResilienceRankingResponse {
|
||||
repeated ResilienceRankingItem items = 1;
|
||||
}
|
||||
21
proto/worldmonitor/resilience/v1/get_resilience_score.proto
Normal file
21
proto/worldmonitor/resilience/v1/get_resilience_score.proto
Normal file
@@ -0,0 +1,21 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.resilience.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/resilience/v1/resilience.proto";
|
||||
|
||||
message GetResilienceScoreRequest {
|
||||
string country_code = 1 [(sebuf.http.query) = { name: "countryCode", required: true }];
|
||||
}
|
||||
|
||||
message GetResilienceScoreResponse {
|
||||
string country_code = 1;
|
||||
double overall_score = 2;
|
||||
string level = 3;
|
||||
repeated ResilienceDomain domains = 4;
|
||||
double cronbach_alpha = 5;
|
||||
string trend = 6;
|
||||
double change_30d = 7;
|
||||
bool low_confidence = 8;
|
||||
}
|
||||
23
proto/worldmonitor/resilience/v1/resilience.proto
Normal file
23
proto/worldmonitor/resilience/v1/resilience.proto
Normal file
@@ -0,0 +1,23 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.resilience.v1;
|
||||
|
||||
message ResilienceDimension {
|
||||
string id = 1;
|
||||
double score = 2;
|
||||
double coverage = 3;
|
||||
}
|
||||
|
||||
message ResilienceDomain {
|
||||
string id = 1;
|
||||
double score = 2;
|
||||
double weight = 3;
|
||||
repeated ResilienceDimension dimensions = 4;
|
||||
}
|
||||
|
||||
message ResilienceRankingItem {
|
||||
string country_code = 1;
|
||||
double overall_score = 2;
|
||||
string level = 3;
|
||||
bool low_confidence = 4;
|
||||
}
|
||||
19
proto/worldmonitor/resilience/v1/service.proto
Normal file
19
proto/worldmonitor/resilience/v1/service.proto
Normal file
@@ -0,0 +1,19 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.resilience.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/resilience/v1/get_resilience_ranking.proto";
|
||||
import "worldmonitor/resilience/v1/get_resilience_score.proto";
|
||||
|
||||
service ResilienceService {
|
||||
option (sebuf.http.service_config) = {base_path: "/api/resilience/v1"};
|
||||
|
||||
rpc GetResilienceScore(GetResilienceScoreRequest) returns (GetResilienceScoreResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-resilience-score", method: HTTP_METHOD_GET};
|
||||
}
|
||||
|
||||
rpc GetResilienceRanking(GetResilienceRankingRequest) returns (GetResilienceRankingResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-resilience-ranking", method: HTTP_METHOD_GET};
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,8 @@ const RPC_CACHE_TIER: Record<string, CacheTier> = {
|
||||
'/api/health/v1/list-disease-outbreaks': 'slow',
|
||||
'/api/health/v1/list-air-quality-alerts': 'fast',
|
||||
'/api/intelligence/v1/get-social-velocity': 'fast',
|
||||
'/api/resilience/v1/get-resilience-score': 'slow',
|
||||
'/api/resilience/v1/get-resilience-ranking': 'slow',
|
||||
};
|
||||
|
||||
import { PREMIUM_RPC_PATHS } from '../src/shared/premium-paths';
|
||||
|
||||
15
server/worldmonitor/resilience/v1/get-resilience-ranking.ts
Normal file
15
server/worldmonitor/resilience/v1/get-resilience-ranking.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type {
|
||||
ResilienceServiceHandler,
|
||||
ServerContext,
|
||||
GetResilienceRankingRequest,
|
||||
GetResilienceRankingResponse,
|
||||
} from '../../../../src/generated/server/worldmonitor/resilience/v1/service_server';
|
||||
|
||||
export const getResilienceRanking: ResilienceServiceHandler['getResilienceRanking'] = async (
|
||||
_ctx: ServerContext,
|
||||
_req: GetResilienceRankingRequest,
|
||||
): Promise<GetResilienceRankingResponse> => {
|
||||
return {
|
||||
items: [],
|
||||
};
|
||||
};
|
||||
31
server/worldmonitor/resilience/v1/get-resilience-score.ts
Normal file
31
server/worldmonitor/resilience/v1/get-resilience-score.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
ResilienceServiceHandler,
|
||||
ServerContext,
|
||||
GetResilienceScoreRequest,
|
||||
GetResilienceScoreResponse,
|
||||
} from '../../../../src/generated/server/worldmonitor/resilience/v1/service_server';
|
||||
import { ValidationError } from '../../../../src/generated/server/worldmonitor/resilience/v1/service_server';
|
||||
|
||||
export const getResilienceScore: ResilienceServiceHandler['getResilienceScore'] = async (
|
||||
_ctx: ServerContext,
|
||||
req: GetResilienceScoreRequest,
|
||||
): Promise<GetResilienceScoreResponse> => {
|
||||
const countryCode = String(req.countryCode || '').trim().toUpperCase();
|
||||
if (!countryCode) {
|
||||
throw new ValidationError([{ field: 'countryCode', description: 'countryCode is required' }]);
|
||||
}
|
||||
if (!/^[A-Z]{2}$/.test(countryCode)) {
|
||||
throw new ValidationError([{ field: 'countryCode', description: 'countryCode must be a 2-letter ISO 3166-1 alpha-2 code' }]);
|
||||
}
|
||||
|
||||
return {
|
||||
countryCode,
|
||||
overallScore: 0,
|
||||
level: 'unknown',
|
||||
domains: [],
|
||||
cronbachAlpha: 0,
|
||||
trend: 'stable',
|
||||
change30d: 0,
|
||||
lowConfidence: true,
|
||||
};
|
||||
};
|
||||
9
server/worldmonitor/resilience/v1/handler.ts
Normal file
9
server/worldmonitor/resilience/v1/handler.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { ResilienceServiceHandler } from '../../../../src/generated/server/worldmonitor/resilience/v1/service_server';
|
||||
|
||||
import { getResilienceRanking } from './get-resilience-ranking';
|
||||
import { getResilienceScore } from './get-resilience-score';
|
||||
|
||||
export const resilienceHandler: ResilienceServiceHandler = {
|
||||
getResilienceScore,
|
||||
getResilienceRanking,
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/resilience/v1/service.proto
|
||||
|
||||
export interface GetResilienceScoreRequest {
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
export interface GetResilienceScoreResponse {
|
||||
countryCode: string;
|
||||
overallScore: number;
|
||||
level: string;
|
||||
domains: ResilienceDomain[];
|
||||
cronbachAlpha: number;
|
||||
trend: string;
|
||||
change30d: number;
|
||||
lowConfidence: boolean;
|
||||
}
|
||||
|
||||
export interface ResilienceDomain {
|
||||
id: string;
|
||||
score: number;
|
||||
weight: number;
|
||||
dimensions: ResilienceDimension[];
|
||||
}
|
||||
|
||||
export interface ResilienceDimension {
|
||||
id: string;
|
||||
score: number;
|
||||
coverage: number;
|
||||
}
|
||||
|
||||
export interface GetResilienceRankingRequest {
|
||||
}
|
||||
|
||||
export interface GetResilienceRankingResponse {
|
||||
items: ResilienceRankingItem[];
|
||||
}
|
||||
|
||||
export interface ResilienceRankingItem {
|
||||
countryCode: string;
|
||||
overallScore: number;
|
||||
level: string;
|
||||
lowConfidence: boolean;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
violations: FieldViolation[];
|
||||
|
||||
constructor(violations: FieldViolation[]) {
|
||||
super("Validation failed");
|
||||
this.name = "ValidationError";
|
||||
this.violations = violations;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
statusCode: number;
|
||||
body: string;
|
||||
|
||||
constructor(statusCode: number, message: string, body: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResilienceServiceClientOptions {
|
||||
fetch?: typeof fetch;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ResilienceServiceCallOptions {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class ResilienceServiceClient {
|
||||
private baseURL: string;
|
||||
private fetchFn: typeof fetch;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
|
||||
constructor(baseURL: string, options?: ResilienceServiceClientOptions) {
|
||||
this.baseURL = baseURL.replace(/\/+$/, "");
|
||||
this.fetchFn = options?.fetch ?? globalThis.fetch;
|
||||
this.defaultHeaders = { ...options?.defaultHeaders };
|
||||
}
|
||||
|
||||
async getResilienceScore(req: GetResilienceScoreRequest, options?: ResilienceServiceCallOptions): Promise<GetResilienceScoreResponse> {
|
||||
let path = "/api/resilience/v1/get-resilience-score";
|
||||
const params = new URLSearchParams();
|
||||
if (req.countryCode != null && req.countryCode !== "") params.set("countryCode", String(req.countryCode));
|
||||
const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : "");
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as GetResilienceScoreResponse;
|
||||
}
|
||||
|
||||
async getResilienceRanking(req: GetResilienceRankingRequest, options?: ResilienceServiceCallOptions): Promise<GetResilienceRankingResponse> {
|
||||
let path = "/api/resilience/v1/get-resilience-ranking";
|
||||
const url = this.baseURL + path;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as GetResilienceRankingResponse;
|
||||
}
|
||||
|
||||
private async handleError(resp: Response): Promise<never> {
|
||||
const body = await resp.text();
|
||||
if (resp.status === 400) {
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (parsed.violations) {
|
||||
throw new ValidationError(parsed.violations);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) throw e;
|
||||
}
|
||||
}
|
||||
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// @ts-nocheck
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/resilience/v1/service.proto
|
||||
|
||||
export interface GetResilienceScoreRequest {
|
||||
countryCode: string;
|
||||
}
|
||||
|
||||
export interface GetResilienceScoreResponse {
|
||||
countryCode: string;
|
||||
overallScore: number;
|
||||
level: string;
|
||||
domains: ResilienceDomain[];
|
||||
cronbachAlpha: number;
|
||||
trend: string;
|
||||
change30d: number;
|
||||
lowConfidence: boolean;
|
||||
}
|
||||
|
||||
export interface ResilienceDomain {
|
||||
id: string;
|
||||
score: number;
|
||||
weight: number;
|
||||
dimensions: ResilienceDimension[];
|
||||
}
|
||||
|
||||
export interface ResilienceDimension {
|
||||
id: string;
|
||||
score: number;
|
||||
coverage: number;
|
||||
}
|
||||
|
||||
export interface GetResilienceRankingRequest {
|
||||
}
|
||||
|
||||
export interface GetResilienceRankingResponse {
|
||||
items: ResilienceRankingItem[];
|
||||
}
|
||||
|
||||
export interface ResilienceRankingItem {
|
||||
countryCode: string;
|
||||
overallScore: number;
|
||||
level: string;
|
||||
lowConfidence: boolean;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
violations: FieldViolation[];
|
||||
|
||||
constructor(violations: FieldViolation[]) {
|
||||
super("Validation failed");
|
||||
this.name = "ValidationError";
|
||||
this.violations = violations;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
statusCode: number;
|
||||
body: string;
|
||||
|
||||
constructor(statusCode: number, message: string, body: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerContext {
|
||||
request: Request;
|
||||
pathParams: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ServerOptions {
|
||||
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
||||
validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined;
|
||||
}
|
||||
|
||||
export interface RouteDescriptor {
|
||||
method: string;
|
||||
path: string;
|
||||
handler: (req: Request) => Promise<Response>;
|
||||
}
|
||||
|
||||
export interface ResilienceServiceHandler {
|
||||
getResilienceScore(ctx: ServerContext, req: GetResilienceScoreRequest): Promise<GetResilienceScoreResponse>;
|
||||
getResilienceRanking(ctx: ServerContext, req: GetResilienceRankingRequest): Promise<GetResilienceRankingResponse>;
|
||||
}
|
||||
|
||||
export function createResilienceServiceRoutes(
|
||||
handler: ResilienceServiceHandler,
|
||||
options?: ServerOptions,
|
||||
): RouteDescriptor[] {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/resilience/v1/get-resilience-score",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const params = url.searchParams;
|
||||
const body: GetResilienceScoreRequest = {
|
||||
countryCode: params.get("countryCode") ?? "",
|
||||
};
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("getResilienceScore", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.getResilienceScore(ctx, body);
|
||||
return new Response(JSON.stringify(result as GetResilienceScoreResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/resilience/v1/get-resilience-ranking",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const body = {} as GetResilienceRankingRequest;
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.getResilienceRanking(ctx, body);
|
||||
return new Response(JSON.stringify(result as GetResilienceRankingResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -194,6 +194,7 @@ function sebufApiPlugin(): Plugin {
|
||||
tradeServerMod, tradeHandlerMod,
|
||||
supplyChainServerMod, supplyChainHandlerMod,
|
||||
naturalServerMod, naturalHandlerMod,
|
||||
resilienceServerMod, resilienceHandlerMod,
|
||||
] = await Promise.all([
|
||||
import('./server/router'),
|
||||
import('./server/cors'),
|
||||
@@ -242,6 +243,8 @@ function sebufApiPlugin(): Plugin {
|
||||
import('./server/worldmonitor/supply-chain/v1/handler'),
|
||||
import('./src/generated/server/worldmonitor/natural/v1/service_server'),
|
||||
import('./server/worldmonitor/natural/v1/handler'),
|
||||
import('./src/generated/server/worldmonitor/resilience/v1/service_server'),
|
||||
import('./server/worldmonitor/resilience/v1/handler'),
|
||||
]);
|
||||
|
||||
const serverOptions = { onError: errorMod.mapErrorToResponse };
|
||||
@@ -268,6 +271,7 @@ function sebufApiPlugin(): Plugin {
|
||||
...tradeServerMod.createTradeServiceRoutes(tradeHandlerMod.tradeHandler, serverOptions),
|
||||
...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions),
|
||||
...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions),
|
||||
...resilienceServerMod.createResilienceServiceRoutes(resilienceHandlerMod.resilienceHandler, serverOptions),
|
||||
];
|
||||
cachedCorsMod = corsMod;
|
||||
return routerMod.createRouter(allRoutes);
|
||||
|
||||
Reference in New Issue
Block a user