@@ -12,6 +12,7 @@ import {
1212 type MediaBinding ,
1313 type OperationParameter ,
1414 type ServerInfo ,
15+ type ServerVariable ,
1516} from "./types" ;
1617
1718// ---------------------------------------------------------------------------
@@ -745,31 +746,82 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* (
745746 } ) ;
746747} ) ;
747748
749+ const urlOrigin = ( value : string ) : string | null => {
750+ return URL . canParse ( value ) ? new URL ( value ) . origin : null ;
751+ } ;
752+
753+ const enumValues = ( variable : ServerVariable | undefined ) : readonly string [ ] | undefined =>
754+ variable ? Option . getOrUndefined ( variable . enum ) : undefined ;
755+
756+ const validateServerVariableOverrides = (
757+ templateUrl : string ,
758+ variables : Record < string , ServerVariable > ,
759+ overrides : Record < string , string > ,
760+ ) : Effect . Effect < void , OpenApiInvocationError > =>
761+ Effect . gen ( function * ( ) {
762+ const defaultUrl = resolveServerUrl ( templateUrl , variables , { } ) ;
763+ const defaultOrigin = urlOrigin ( defaultUrl ) ;
764+ const resolvedUrl = resolveServerUrl ( templateUrl , variables , overrides ) ;
765+ const resolvedOrigin = urlOrigin ( resolvedUrl ) ;
766+
767+ for ( const [ name , value ] of Object . entries ( overrides ) ) {
768+ const variable = variables [ name ] ;
769+ const allowed = enumValues ( variable ) ;
770+ if ( allowed && ! allowed . includes ( value ) ) {
771+ return yield * new OpenApiInvocationError ( {
772+ message : `Server variable "${ name } " must be one of: ${ allowed . join ( ", " ) } ` ,
773+ statusCode : Option . none ( ) ,
774+ } ) ;
775+ }
776+ }
777+
778+ if ( ! defaultOrigin || ! resolvedOrigin || defaultOrigin === resolvedOrigin ) return ;
779+
780+ const unsafe = Object . keys ( overrides ) . filter ( ( name ) => {
781+ const variable = variables [ name ] ;
782+ if ( ! variable || enumValues ( variable ) ) return false ;
783+ const singleOrigin = urlOrigin (
784+ resolveServerUrl ( templateUrl , variables , { [ name ] : overrides [ name ] ! } ) ,
785+ ) ;
786+ return singleOrigin !== null && singleOrigin !== defaultOrigin ;
787+ } ) ;
788+
789+ if ( unsafe . length > 0 ) {
790+ return yield * new OpenApiInvocationError ( {
791+ message : `Server variable override cannot change request origin: ${ unsafe . join ( ", " ) } ` ,
792+ statusCode : Option . none ( ) ,
793+ } ) ;
794+ }
795+ } ) ;
796+
748797// Connection `baseUrl` wins; otherwise the call's chosen server (`server.url`, or
749798// the first) resolved with its `{variables}` (call values, else spec defaults).
750799const resolveRequestHost = (
751800 servers : readonly ServerInfo [ ] ,
752801 serverArg : unknown ,
753802 baseUrl : string ,
754- ) : string => {
755- if ( baseUrl ) return baseUrl ;
756- if ( servers . length === 0 ) return "" ;
757-
758- const arg = (
759- typeof serverArg === "object" && serverArg !== null && ! Array . isArray ( serverArg )
760- ? serverArg
761- : { }
762- ) as { url ?: unknown ; variables ?: unknown } ;
763- const chosen = servers . find ( ( server ) => server . url === arg . url ) ?? servers [ 0 ] ! ;
764-
765- const overrides : Record < string , string > = { } ;
766- if ( typeof arg . variables === "object" && arg . variables !== null ) {
767- for ( const [ name , value ] of Object . entries ( arg . variables as Record < string , unknown > ) ) {
768- if ( value != null && value !== "" ) overrides [ name ] = String ( value ) ;
803+ ) : Effect . Effect < string , OpenApiInvocationError > =>
804+ Effect . gen ( function * ( ) {
805+ if ( baseUrl ) return baseUrl ;
806+ if ( servers . length === 0 ) return "" ;
807+
808+ const arg = (
809+ typeof serverArg === "object" && serverArg !== null && ! Array . isArray ( serverArg )
810+ ? serverArg
811+ : { }
812+ ) as { url ?: unknown ; variables ?: unknown } ;
813+ const chosen = servers . find ( ( server ) => server . url === arg . url ) ?? servers [ 0 ] ! ;
814+
815+ const overrides : Record < string , string > = { } ;
816+ if ( typeof arg . variables === "object" && arg . variables !== null ) {
817+ for ( const [ name , value ] of Object . entries ( arg . variables as Record < string , unknown > ) ) {
818+ if ( value != null && value !== "" ) overrides [ name ] = String ( value ) ;
819+ }
769820 }
770- }
771- return resolveServerUrl ( chosen . url , Option . getOrUndefined ( chosen . variables ) , overrides ) ;
772- } ;
821+ const variables = Option . getOrUndefined ( chosen . variables ) ?? { } ;
822+ yield * validateServerVariableOverrides ( chosen . url , variables , overrides ) ;
823+ return resolveServerUrl ( chosen . url , variables , overrides ) ;
824+ } ) ;
773825
774826// ---------------------------------------------------------------------------
775827// Invoke with a provided HttpClient layer + per-call host resolution
@@ -783,32 +835,38 @@ export const invokeWithLayer = (
783835 sourceQueryParams : Record < string , string > ,
784836 httpClientLayer : Layer . Layer < HttpClient . HttpClient , never , never > ,
785837) => {
786- const effectiveBaseUrl = resolveRequestHost ( operation . servers ?? [ ] , args . server , baseUrl ) ;
787- const clientWithBaseUrl = effectiveBaseUrl
788- ? Layer . effect (
789- HttpClient . HttpClient ,
790- Effect . map (
791- Effect . service ( HttpClient . HttpClient ) ,
792- HttpClient . mapRequest ( HttpClientRequest . prependUrl ( effectiveBaseUrl ) ) ,
793- ) ,
794- ) . pipe ( Layer . provide ( httpClientLayer ) )
795- : httpClientLayer ;
796-
797- return invoke ( operation , args , resolvedHeaders , sourceQueryParams ) . pipe (
798- Effect . provide ( clientWithBaseUrl ) ,
799- // `invoke` annotates http.status_code on ITS span (`OpenApi.invoke`,
800- // via Effect.fn) — annotateCurrentSpan inside it never reaches this
801- // wrapper span. Stamp the status here too so queries against
802- // `plugin.openapi.invoke` see the upstream outcome directly.
803- Effect . tap ( ( result ) => Effect . annotateCurrentSpan ( { "http.status_code" : result . status } ) ) ,
804- Effect . withSpan ( "plugin.openapi.invoke" , {
805- attributes : {
806- "plugin.openapi.method" : operation . method . toUpperCase ( ) ,
807- "plugin.openapi.path_template" : operation . pathTemplate ,
808- "plugin.openapi.base_url" : effectiveBaseUrl ,
809- } ,
810- } ) ,
811- ) ;
838+ return Effect . gen ( function * ( ) {
839+ const effectiveBaseUrl = yield * resolveRequestHost (
840+ operation . servers ?? [ ] ,
841+ args . server ,
842+ baseUrl ,
843+ ) ;
844+ const clientWithBaseUrl = effectiveBaseUrl
845+ ? Layer . effect (
846+ HttpClient . HttpClient ,
847+ Effect . map (
848+ Effect . service ( HttpClient . HttpClient ) ,
849+ HttpClient . mapRequest ( HttpClientRequest . prependUrl ( effectiveBaseUrl ) ) ,
850+ ) ,
851+ ) . pipe ( Layer . provide ( httpClientLayer ) )
852+ : httpClientLayer ;
853+
854+ return yield * invoke ( operation , args , resolvedHeaders , sourceQueryParams ) . pipe (
855+ Effect . provide ( clientWithBaseUrl ) ,
856+ // `invoke` annotates http.status_code on ITS span (`OpenApi.invoke`,
857+ // via Effect.fn), annotateCurrentSpan inside it never reaches this
858+ // wrapper span. Stamp the status here too so queries against
859+ // `plugin.openapi.invoke` see the upstream outcome directly.
860+ Effect . tap ( ( result ) => Effect . annotateCurrentSpan ( { "http.status_code" : result . status } ) ) ,
861+ Effect . withSpan ( "plugin.openapi.invoke" , {
862+ attributes : {
863+ "plugin.openapi.method" : operation . method . toUpperCase ( ) ,
864+ "plugin.openapi.path_template" : operation . pathTemplate ,
865+ "plugin.openapi.base_url" : effectiveBaseUrl ,
866+ } ,
867+ } ) ,
868+ ) ;
869+ } ) ;
812870} ;
813871
814872// ---------------------------------------------------------------------------
0 commit comments