@@ -10,7 +10,9 @@ use serde_json::{Map, Value, json};
1010use tracing:: debug;
1111use url:: Url ;
1212
13- use crate :: apps:: app:: { App , AppResource , AppTool , PrefetchOperation } ;
13+ use crate :: apps:: app:: {
14+ App , AppResource , AppResourceSource , AppTool , PrefetchOperation , TargetedAppResource ,
15+ } ;
1416use crate :: {
1517 custom_scalar_map:: CustomScalarMap ,
1618 operations:: { MutationMode , Operation , RawOperation } ,
@@ -148,22 +150,21 @@ pub(crate) fn load_from_path(
148150 }
149151 }
150152
151- let resource = if manifest. resource . starts_with ( "http://" )
152- || manifest. resource . starts_with ( "https://" )
153- {
154- let url = Url :: parse ( & manifest. resource ) . map_err ( |err| {
155- format ! ( "Failed to parse resource URL {}: {err}" , manifest. resource)
156- } ) ?;
157- AppResource :: Remote ( url)
158- } else {
159- let resource_path = path. join ( & manifest. resource ) ;
160- let contents = read_to_string ( & resource_path) . map_err ( |err| {
161- format ! (
162- "Failed to read resource from {resource_path}: {err}" ,
163- resource_path = resource_path. to_string_lossy( ) ,
164- )
165- } ) ?;
166- AppResource :: Local ( contents)
153+ // We may have a resource with just a single string OR an object specifying mcp + openai resources. Any of these could be file path or url.
154+ let resource = match manifest. resource {
155+ ManifestResource :: Targeted ( targets) => AppResource :: Targeted ( TargetedAppResource {
156+ mcp : targets
157+ . mcp
158+ . map ( |resource| resource_source_from_string ( resource, & path) )
159+ . transpose ( ) ?,
160+ openai : targets
161+ . openai
162+ . map ( |resource| resource_source_from_string ( resource, & path) )
163+ . transpose ( ) ?,
164+ } ) ,
165+ ManifestResource :: Single ( resource) => {
166+ AppResource :: Single ( resource_source_from_string ( resource, & path) ?)
167+ }
167168 } ;
168169
169170 apps. push ( App {
@@ -180,6 +181,23 @@ pub(crate) fn load_from_path(
180181 Ok ( apps)
181182}
182183
184+ fn resource_source_from_string ( resource : String , path : & Path ) -> Result < AppResourceSource , String > {
185+ if resource. starts_with ( "http://" ) || resource. starts_with ( "https://" ) {
186+ let url = Url :: parse ( & resource)
187+ . map_err ( |err| format ! ( "Failed to parse resource URL {}: {err}" , resource) ) ?;
188+ Ok ( AppResourceSource :: Remote ( url) )
189+ } else {
190+ let resource_path = path. join ( & resource) ;
191+ let contents = read_to_string ( & resource_path) . map_err ( |err| {
192+ format ! (
193+ "Failed to read resource from {resource_path}: {err}" ,
194+ resource_path = resource_path. to_string_lossy( ) ,
195+ )
196+ } ) ?;
197+ Ok ( AppResourceSource :: Local ( contents) )
198+ }
199+ }
200+
183201fn merge_inputs (
184202 orig : & mut Map < String , Value > ,
185203 extra : Vec < ExtraInputDefinition > ,
@@ -228,7 +246,7 @@ fn merge_inputs(
228246struct Manifest {
229247 hash : String ,
230248 operations : Vec < OperationDefinition > ,
231- resource : String ,
249+ resource : ManifestResource ,
232250 name : Option < String > ,
233251 description : Option < String > ,
234252 csp : Option < CSPSettings > ,
@@ -241,6 +259,19 @@ struct Manifest {
241259 version : ManifestVersion ,
242260}
243261
262+ #[ derive( Clone , Deserialize ) ]
263+ #[ serde( untagged) ]
264+ enum ManifestResource {
265+ Targeted ( TargetedManifestResource ) ,
266+ Single ( String ) ,
267+ }
268+
269+ #[ derive( Clone , Deserialize ) ]
270+ pub ( crate ) struct TargetedManifestResource {
271+ openai : Option < String > ,
272+ mcp : Option < String > ,
273+ }
274+
244275#[ derive( Clone , Copy , Deserialize ) ]
245276#[ serde( rename_all = "kebab-case" ) ]
246277enum ManifestFormat {
@@ -341,6 +372,7 @@ pub(crate) struct CSPSettings {
341372#[ cfg( test) ]
342373mod test_load_from_path {
343374 use super :: * ;
375+ use crate :: apps:: app:: AppResourceSource ;
344376 use assert_fs:: { TempDir , prelude:: * } ;
345377
346378 #[ test]
@@ -375,8 +407,10 @@ mod test_load_from_path {
375407 assert_eq ! ( apps. len( ) , 1 ) ;
376408 let app = & apps[ 0 ] ;
377409 match & app. resource {
378- AppResource :: Local ( contents) => assert_eq ! ( contents, html) ,
379- AppResource :: Remote ( url) => panic ! ( "unexpected remote resource {url}" ) ,
410+ AppResource :: Single ( AppResourceSource :: Local ( contents) ) => {
411+ assert_eq ! ( contents, html)
412+ }
413+ other => panic ! ( "unexpected resource {other:?}" ) ,
380414 }
381415 assert_eq ! ( app. uri, "ui://widget/MyApp#abcdef" . parse( ) . unwrap( ) ) ;
382416 }
@@ -411,11 +445,11 @@ mod test_load_from_path {
411445 assert_eq ! ( apps. len( ) , 1 ) ;
412446 let app = & apps[ 0 ] ;
413447 match & app. resource {
414- AppResource :: Remote ( url) => {
448+ AppResource :: Single ( AppResourceSource :: Remote ( url) ) => {
415449 assert_eq ! ( url. as_str( ) , "https://example.com/widget/index.html" )
416450 }
417- AppResource :: Local ( contents ) => {
418- panic ! ( "expected remote resource, found local : {contents }" )
451+ other => {
452+ panic ! ( "expected remote resource, found: {other:? }" )
419453 }
420454 }
421455 }
@@ -929,4 +963,114 @@ mod test_load_from_path {
929963 Some ( "Cart filled!" . to_string( ) )
930964 ) ;
931965 }
966+
967+ #[ test]
968+ fn should_load_local_files_when_resource_is_targeted ( ) {
969+ let temp = TempDir :: new ( ) . expect ( "Could not create temporary directory for test" ) ;
970+ let app_dir = temp. child ( "TargetedApp" ) ;
971+ app_dir
972+ . child ( MANIFEST_FILE_NAME )
973+ . write_str (
974+ r#"{"format": "apollo-ai-app-manifest",
975+ "version": "1",
976+ "hash": "abcdef",
977+ "resource": {
978+ "openai": "openai.html",
979+ "mcp": "mcp.html"
980+ },
981+ "operations": []}"# ,
982+ )
983+ . unwrap ( ) ;
984+ let openai_html = "<html>openai</html>" ;
985+ let mcp_html = "<html>mcp</html>" ;
986+ app_dir. child ( "openai.html" ) . write_str ( openai_html) . unwrap ( ) ;
987+ app_dir. child ( "mcp.html" ) . write_str ( mcp_html) . unwrap ( ) ;
988+ let apps = load_from_path (
989+ temp. path ( ) ,
990+ & Schema :: parse ( "type Query { hello: String }" , "schema.graphql" )
991+ . unwrap ( )
992+ . validate ( )
993+ . unwrap ( ) ,
994+ None ,
995+ MutationMode :: All ,
996+ false ,
997+ false ,
998+ true ,
999+ )
1000+ . expect ( "Failed to load apps" ) ;
1001+ assert_eq ! ( apps. len( ) , 1 ) ;
1002+ let app = & apps[ 0 ] ;
1003+ match & app. resource {
1004+ AppResource :: Targeted ( targeted) => {
1005+ match targeted
1006+ . openai
1007+ . as_ref ( )
1008+ . expect ( "openai resource should exist" )
1009+ {
1010+ AppResourceSource :: Local ( contents) => assert_eq ! ( contents, openai_html) ,
1011+ other => panic ! ( "expected local openai resource, found: {other:?}" ) ,
1012+ }
1013+ match targeted. mcp . as_ref ( ) . expect ( "mcp resource should exist" ) {
1014+ AppResourceSource :: Local ( contents) => assert_eq ! ( contents, mcp_html) ,
1015+ other => panic ! ( "expected local mcp resource, found: {other:?}" ) ,
1016+ }
1017+ }
1018+ other => panic ! ( "expected targeted resource, found: {other:?}" ) ,
1019+ }
1020+ }
1021+
1022+ #[ test]
1023+ fn should_load_remote_urls_when_resource_is_targeted ( ) {
1024+ let temp = TempDir :: new ( ) . expect ( "Could not create temporary directory for test" ) ;
1025+ let app_dir = temp. child ( "TargetedRemoteApp" ) ;
1026+ app_dir
1027+ . child ( MANIFEST_FILE_NAME )
1028+ . write_str (
1029+ r#"{"format": "apollo-ai-app-manifest",
1030+ "version": "1",
1031+ "hash": "abcdef",
1032+ "resource": {
1033+ "openai": "https://example.com/openai.html",
1034+ "mcp": "https://example.com/mcp.html"
1035+ },
1036+ "operations": []}"# ,
1037+ )
1038+ . unwrap ( ) ;
1039+ let apps = load_from_path (
1040+ temp. path ( ) ,
1041+ & Schema :: parse ( "type Query { hello: String }" , "schema.graphql" )
1042+ . unwrap ( )
1043+ . validate ( )
1044+ . unwrap ( ) ,
1045+ None ,
1046+ MutationMode :: All ,
1047+ false ,
1048+ false ,
1049+ true ,
1050+ )
1051+ . expect ( "Failed to load apps" ) ;
1052+ assert_eq ! ( apps. len( ) , 1 ) ;
1053+ let app = & apps[ 0 ] ;
1054+ match & app. resource {
1055+ AppResource :: Targeted ( targeted) => {
1056+ match targeted
1057+ . openai
1058+ . as_ref ( )
1059+ . expect ( "openai resource should exist" )
1060+ {
1061+ AppResourceSource :: Remote ( url) => {
1062+ assert_eq ! ( url. as_str( ) , "https://example.com/openai.html" )
1063+ }
1064+ other => panic ! ( "expected remote openai resource, found: {other:?}" ) ,
1065+ }
1066+ match targeted. mcp . as_ref ( ) . expect ( "mcp resource should exist" ) {
1067+ AppResourceSource :: Remote ( url) => {
1068+ assert_eq ! ( url. as_str( ) , "https://example.com/mcp.html" )
1069+ }
1070+ other => panic ! ( "expected remote mcp resource, found: {other:?}" ) ,
1071+ }
1072+ }
1073+ other => panic ! ( "expected targeted resource, found: {other:?}" ) ,
1074+ }
1075+ }
9321076}
0 commit comments