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-disease-outbreaks': 'slow',
|
||||||
'/api/health/v1/list-air-quality-alerts': 'fast',
|
'/api/health/v1/list-air-quality-alerts': 'fast',
|
||||||
'/api/intelligence/v1/get-social-velocity': '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';
|
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,
|
tradeServerMod, tradeHandlerMod,
|
||||||
supplyChainServerMod, supplyChainHandlerMod,
|
supplyChainServerMod, supplyChainHandlerMod,
|
||||||
naturalServerMod, naturalHandlerMod,
|
naturalServerMod, naturalHandlerMod,
|
||||||
|
resilienceServerMod, resilienceHandlerMod,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
import('./server/router'),
|
import('./server/router'),
|
||||||
import('./server/cors'),
|
import('./server/cors'),
|
||||||
@@ -242,6 +243,8 @@ function sebufApiPlugin(): Plugin {
|
|||||||
import('./server/worldmonitor/supply-chain/v1/handler'),
|
import('./server/worldmonitor/supply-chain/v1/handler'),
|
||||||
import('./src/generated/server/worldmonitor/natural/v1/service_server'),
|
import('./src/generated/server/worldmonitor/natural/v1/service_server'),
|
||||||
import('./server/worldmonitor/natural/v1/handler'),
|
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 };
|
const serverOptions = { onError: errorMod.mapErrorToResponse };
|
||||||
@@ -268,6 +271,7 @@ function sebufApiPlugin(): Plugin {
|
|||||||
...tradeServerMod.createTradeServiceRoutes(tradeHandlerMod.tradeHandler, serverOptions),
|
...tradeServerMod.createTradeServiceRoutes(tradeHandlerMod.tradeHandler, serverOptions),
|
||||||
...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions),
|
...supplyChainServerMod.createSupplyChainServiceRoutes(supplyChainHandlerMod.supplyChainHandler, serverOptions),
|
||||||
...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions),
|
...naturalServerMod.createNaturalServiceRoutes(naturalHandlerMod.naturalHandler, serverOptions),
|
||||||
|
...resilienceServerMod.createResilienceServiceRoutes(resilienceHandlerMod.resilienceHandler, serverOptions),
|
||||||
];
|
];
|
||||||
cachedCorsMod = corsMod;
|
cachedCorsMod = corsMod;
|
||||||
return routerMod.createRouter(allRoutes);
|
return routerMod.createRouter(allRoutes);
|
||||||
|
|||||||
Reference in New Issue
Block a user