namespace WoofWare.Myriad.Plugins open System open System.Net.Http open Fantomas.FCS.Syntax open Fantomas.FCS.SyntaxTrivia open Fantomas.FCS.Xml open Myriad.Core /// Attribute indicating a record type to which the "create HTTP client" Myriad /// generator should apply during build. type HttpClientAttribute () = inherit Attribute () [] module internal HttpClientGenerator = open Fantomas.FCS.Text.Range open Myriad.Core.Ast type HttpAttribute = // TODO: Format parameter to these attrs | Query of string option | Path of string | Body type Parameter = { Attributes : HttpAttribute list IsOptional : bool Id : Ident option Type : SynType } [] type BodyParamMethods = | StringContent | StreamContent | ByteArrayContent | HttpContent | Serialise of SynType override this.ToString () = match this with | BodyParamMethods.Serialise _ -> "ToString" | BodyParamMethods.ByteArrayContent -> "ByteArrayContent" | BodyParamMethods.StringContent -> "StringContent" | BodyParamMethods.StreamContent -> "StreamContent" | BodyParamMethods.HttpContent -> "HttpContent" let synBindingTriviaZero (isMember : bool) = { SynBindingTrivia.EqualsRange = Some range0 InlineKeyword = None LeadingKeyword = if isMember then SynLeadingKeyword.Member range0 else SynLeadingKeyword.Let range0 } type MemberInfo = { /// E.g. HttpMethod.Get HttpMethod : HttpMethod /// E.g. "v1/gyms/{gym_id}/attendance" UrlTemplate : string ReturnType : SynType Arity : SynArgInfo list Args : Parameter list Identifier : Ident EnsureSuccessHttpCode : bool BaseAddress : SynExpr option BasePath : SynExpr option } let httpMethodString (m : HttpMethod) : string = if m = HttpMethod.Get then "Get" elif m = HttpMethod.Post then "Post" elif m = HttpMethod.Delete then "Delete" elif m = HttpMethod.Patch then "Post" elif m = HttpMethod.Options then "Options" elif m = HttpMethod.Head then "Head" elif m = HttpMethod.Put then "Put" elif m = HttpMethod.Trace then "Trace" else failwith $"Unrecognised method: %+A{m}" /// E.g. converts `[]` to (HttpMethod.Get, "blah") let extractHttpInformation (attrs : SynAttribute list) : HttpMethod * string = let matchingAttrs = attrs |> List.choose (fun attr -> match attr.TypeName.AsString with | "Get" | "GetAttribute" | "RestEase.Get" | "RestEase.GetAttribute" -> Some (HttpMethod.Get, attr.ArgExpr) | "Post" | "PostAttribute" | "RestEase.Post" | "RestEase.PostAttribute" -> Some (HttpMethod.Post, attr.ArgExpr) | "Put" | "PutAttribute" | "RestEase.Put" | "RestEase.PutAttribute" -> Some (HttpMethod.Put, attr.ArgExpr) | "Delete" | "DeleteAttribute" | "RestEase.Delete" | "RestEase.DeleteAttribute" -> Some (HttpMethod.Delete, attr.ArgExpr) | "Head" | "HeadAttribute" | "RestEase.Head" | "RestEase.HeadAttribute" -> Some (HttpMethod.Head, attr.ArgExpr) | "Options" | "OptionsAttribute" | "RestEase.Options" | "RestEase.OptionsAttribute" -> Some (HttpMethod.Options, attr.ArgExpr) | "Patch" | "PatchAttribute" | "RestEase.Patch" | "RestEase.PatchAttribute" -> Some (HttpMethod.Patch, attr.ArgExpr) | "Trace" | "TraceAttribute" | "RestEase.Trace" | "RestEase.TraceAttribute" -> Some (HttpMethod.Trace, attr.ArgExpr) | _ -> None ) match matchingAttrs with | [ (meth, arg) ] -> match arg with | SynExpr.Const (SynConst.String (text, SynStringKind.Regular, _), _) -> meth, text | arg -> failwithf "Unrecognised AST member in attribute argument. Only regular strings are supported: %+A" arg | [] -> failwith "Required exactly one recognised RestEase attribute on member, but got none" | matchingAttrs -> failwithf "Required exactly one recognised RestEase attribute on member, but got %i" matchingAttrs.Length let shouldAllowAnyStatusCode (attrs : SynAttribute list) : bool = attrs |> List.exists (fun attr -> match attr.TypeName.AsString with | "AllowAnyStatusCode" | "AllowAnyStatusCodeAttribute" | "RestEase.AllowAnyStatusCode" | "RestEase.AllowAnyStatusCodeAttribute" -> true | _ -> false ) let constructMember (info : MemberInfo) : SynMemberDefn = let valInfo = SynValInfo.SynValInfo ( [ [ SynArgInfo.Empty ] [ for arg in info.Args do match arg.Id with | None -> yield SynArgInfo.CreateIdString (failwith "TODO: create an arg name") | Some id -> yield SynArgInfo.CreateId id ] ], SynArgInfo.Empty ) let valData = SynValData ( Some { IsInstance = true IsDispatchSlot = false IsOverrideOrExplicitImpl = true IsFinal = false GetterOrSetterIsCompilerGenerated = false MemberKind = SynMemberKind.Member }, valInfo, None ) let argPats = let args = info.Args |> List.map (fun arg -> let argName = match arg.Id with | None -> failwith "TODO: create an arg name" | Some id -> id let argType = if arg.IsOptional then SynType.CreateApp ( SynType.CreateLongIdent (SynLongIdent.CreateString "option"), [ arg.Type ], isPostfix = true ) else arg.Type SynPat.CreateTyped (SynPat.CreateNamed argName, argType) ) SynPat.Tuple (false, args, List.replicate (args.Length - 1) range0, range0) |> SynPat.CreateParen |> List.singleton |> SynArgPats.Pats let headPat = SynPat.LongIdent ( SynLongIdent.CreateFromLongIdent [ Ident.Create "_" ; info.Identifier ], None, None, argPats, None, range0 ) let requestUriTrailer = (SynExpr.CreateConstString info.UrlTemplate, info.Args) ||> List.fold (fun template arg -> (template, arg.Attributes) ||> List.fold (fun template attr -> match attr with | HttpAttribute.Path s -> let varName = match arg.Id with | None -> failwith "TODO: anonymous args" | Some id -> id template |> SynExpr.callMethodArg "Replace" (SynExpr.CreateParenedTuple [ SynExpr.CreateConstString ("{" + s + "}") SynExpr.callMethod "ToString" (SynExpr.CreateIdent varName) |> SynExpr.pipeThroughFunction ( SynExpr.CreateLongIdent ( SynLongIdent.Create [ "System" ; "Web" ; "HttpUtility" ; "UrlEncode" ] ) ) ]) | _ -> template ) ) /// List of (query-param-key, parameter-which-provides-value) let queryParams = info.Args |> List.collect (fun arg -> arg.Attributes |> List.choose (fun attr -> match attr with | Query None -> let name = match arg.Id with | None -> failwith "Expected a name for the argument we're trying to use as an anonymous query parameter" | Some name -> name.idText Some (name, arg) | Query (Some name) -> Some (name, arg) | _ -> None ) ) let requestUriTrailer = match queryParams with | [] -> requestUriTrailer | (firstKey, firstValue) :: queryParams -> let firstValueId = match firstValue.Id with | None -> failwith "Unable to get parameter variable name from anonymous parameter" | Some id -> id let prefix = SynExpr.CreateIdent firstValueId |> SynExpr.toString firstValue.Type |> SynExpr.CreateParen |> SynExpr.pipeThroughFunction ( SynExpr.CreateLongIdent (SynLongIdent.Create [ "System" ; "Web" ; "HttpUtility" ; "UrlEncode" ]) ) |> SynExpr.CreateParen |> SynExpr.plus (SynExpr.CreateConstString ("?" + firstKey + "=")) (prefix, queryParams) ||> List.fold (fun uri (paramKey, paramValue) -> let paramValueId = match paramValue.Id with | None -> failwith "Unable to get parameter variable name from anonymous parameter" | Some id -> id SynExpr.toString paramValue.Type (SynExpr.CreateIdent paramValueId) |> SynExpr.CreateParen |> SynExpr.pipeThroughFunction ( SynExpr.CreateLongIdent ( SynLongIdent.Create [ "System" ; "Web" ; "HttpUtility" ; "UrlEncode" ] ) ) |> SynExpr.CreateParen |> SynExpr.plus (SynExpr.plus uri (SynExpr.CreateConstString ("&" + paramKey + "="))) ) |> SynExpr.plus requestUriTrailer |> SynExpr.CreateParen let requestUri = let uriIdent = SynExpr.CreateLongIdent (SynLongIdent.Create [ "System" ; "Uri" ]) let baseAddress = SynExpr.CreateLongIdent (SynLongIdent.Create [ "client" ; "BaseAddress" ]) let baseAddress = SynExpr.CreateMatch ( baseAddress, [ SynMatchClause.Create ( SynPat.CreateNull, None, match info.BaseAddress with | None -> SynExpr.CreateApp ( SynExpr.CreateIdentString "raise", SynExpr.CreateParen ( SynExpr.CreateApp ( SynExpr.CreateLongIdent ( SynLongIdent.Create [ "System" ; "ArgumentNullException" ] ), SynExpr.CreateParenedTuple [ SynExpr.CreateApp ( SynExpr.CreateIdentString "nameof", SynExpr.CreateParen baseAddress ) SynExpr.CreateConstString "No base address was supplied on the type, and no BaseAddress was on the HttpClient." ] ) ) ) | Some expr -> SynExpr.CreateApp (uriIdent, expr) ) SynMatchClause.Create ( SynPat.CreateNamed (Ident.Create "v"), None, SynExpr.CreateIdentString "v" ) ] ) |> SynExpr.CreateParen SynExpr.App ( ExprAtomicFlag.Atomic, false, uriIdent, SynExpr.CreateParenedTuple [ baseAddress SynExpr.CreateApp ( uriIdent, SynExpr.CreateParenedTuple [ requestUriTrailer SynExpr.CreateLongIdent (SynLongIdent.Create [ "System" ; "UriKind" ; "Relative" ]) ] ) ], range0 ) let bodyParams = info.Args |> List.collect (fun arg -> arg.Attributes |> List.choose (fun attr -> match attr with | Body -> Some arg | _ -> None ) ) let bodyParam = match bodyParams with | [] -> None | [ x ] -> // TODO: body serialisation method let paramName = match x.Id with | None -> failwith "Anonymous [] parameter is unsupported" | Some id -> id match x.Type with | Stream -> Some (BodyParamMethods.StreamContent, paramName) | String -> Some (BodyParamMethods.StringContent, paramName) | ArrayType Byte -> Some (BodyParamMethods.ByteArrayContent, paramName) | HttpContent -> Some (BodyParamMethods.HttpContent, paramName) | ty -> Some (BodyParamMethods.Serialise ty, paramName) | _ -> failwith "You can only have at most one [] parameter on a method." let httpReqMessageConstructor = [ SynExpr.equals (SynExpr.CreateIdentString "Method") (SynExpr.CreateLongIdent ( SynLongIdent.Create [ "System" ; "Net" ; "Http" ; "HttpMethod" ; httpMethodString info.HttpMethod ] )) SynExpr.equals (SynExpr.CreateIdentString "RequestUri") (SynExpr.CreateIdentString "uri") ] |> SynExpr.CreateParenedTuple let returnExpr = match info.ReturnType with | HttpResponseMessage | String | Stream -> SynExpr.CreateIdentString "node" | _ -> JsonParseGenerator.parseNode None JsonParseGenerator.JsonParseOption.None info.ReturnType (SynExpr.CreateIdentString "node") let handleBodyParams = match bodyParam with | None -> [] | Some (bodyParamType, bodyParamName) -> match bodyParamType with | BodyParamMethods.StreamContent | BodyParamMethods.ByteArrayContent | BodyParamMethods.StringContent -> [ Let ( "queryParams", SynExpr.New ( false, SynType.CreateLongIdent ( SynLongIdent.Create [ "System" ; "Net" ; "Http" ; (bodyParamType : BodyParamMethods).ToString () ] ), SynExpr.CreateParen (SynExpr.CreateIdent bodyParamName), range0 ) ) Do ( SynExpr.LongIdentSet ( SynLongIdent.Create [ "httpMessage" ; "Content" ], SynExpr.CreateIdentString "queryParams", range0 ) ) ] | BodyParamMethods.HttpContent -> [ Do ( SynExpr.LongIdentSet ( SynLongIdent.Create [ "httpMessage" ; "Content" ], SynExpr.CreateIdent bodyParamName, range0 ) ) ] | BodyParamMethods.Serialise _ -> failwith "We don't yet support serialising Body parameters; use string or Stream instead" (* // TODO: this should use JSON instead of ToString [ Let ( "queryParams", SynExpr.New ( false, SynType.CreateLongIdent ( SynLongIdent.Create [ "System" ; "Net" ; "Http" ; "StringContent" ] ), SynExpr.CreateParen (SynExpr.CreateIdent bodyParamName |> SynExpr.toString ty), range0 ) ) Do ( SynExpr.LongIdentSet ( SynLongIdent.Create [ "httpMessage" ; "Content" ], SynExpr.CreateIdentString "queryParams", range0 ) ) ] *) let implementation = [ yield LetBang ("ct", SynExpr.CreateLongIdent (SynLongIdent.Create [ "Async" ; "CancellationToken" ])) yield Let ("uri", requestUri) yield Use ( "httpMessage", SynExpr.New ( false, SynType.CreateLongIdent ( SynLongIdent.Create [ "System" ; "Net" ; "Http" ; "HttpRequestMessage" ] ), httpReqMessageConstructor, range0 ) ) yield! handleBodyParams yield LetBang ( "response", SynExpr.awaitTask ( SynExpr.CreateApp ( SynExpr.CreateLongIdent (SynLongIdent.Create [ "client" ; "SendAsync" ]), SynExpr.CreateParenedTuple [ SynExpr.CreateIdentString "httpMessage" ; SynExpr.CreateIdentString "ct" ] ) ) ) if info.EnsureSuccessHttpCode then yield Let ( "response", SynExpr.CreateApp ( SynExpr.CreateLongIdent (SynLongIdent.Create [ "response" ; "EnsureSuccessStatusCode" ]), SynExpr.CreateConst SynConst.Unit ) ) match info.ReturnType with | HttpResponseMessage -> yield Let ("node", SynExpr.CreateIdentString "response") | String -> yield LetBang ( "node", SynExpr.awaitTask ( SynExpr.CreateApp ( SynExpr.CreateLongIdent ( SynLongIdent.Create [ "response" ; "Content" ; "ReadAsStringAsync" ] ), SynExpr.CreateIdentString "ct" ) ) ) | Stream -> yield LetBang ( "node", SynExpr.awaitTask ( SynExpr.CreateApp ( SynExpr.CreateLongIdent ( SynLongIdent.Create [ "response" ; "Content" ; "ReadAsStreamAsync" ] ), SynExpr.CreateIdentString "ct" ) ) ) | _ -> yield LetBang ( "stream", SynExpr.awaitTask ( SynExpr.CreateApp ( SynExpr.CreateLongIdent ( SynLongIdent.Create [ "response" ; "Content" ; "ReadAsStreamAsync" ] ), SynExpr.CreateIdentString "ct" ) ) ) yield LetBang ( "node", SynExpr.awaitTask ( SynExpr.CreateApp ( SynExpr.CreateLongIdent ( SynLongIdent.Create [ "System" ; "Text" ; "Json" ; "Nodes" ; "JsonNode" ; "ParseAsync" ] ), SynExpr.CreateParenedTuple [ SynExpr.CreateIdentString "stream" SynExpr.equals (SynExpr.CreateIdentString "cancellationToken") (SynExpr.CreateIdentString "ct") ] ) ) ) ] |> SynExpr.createCompExpr "async" returnExpr |> SynExpr.startAsTask SynMemberDefn.Member ( SynBinding.SynBinding ( None, SynBindingKind.Normal, false, false, [], PreXmlDoc.Empty, valData, headPat, None, implementation, range0, DebugPointAtBinding.Yes range0, synBindingTriviaZero true ), range0 ) let rec convertSigParam (ty : SynType) : Parameter = match ty with | SynType.Paren (inner, _) -> convertSigParam inner | SynType.SignatureParameter (attrs, opt, id, usedType, _) -> let attrs = attrs |> List.collect (fun attrs -> attrs.Attributes |> List.choose (fun attr -> match attr.TypeName.AsString with | "Query" | "QueryAttribute" -> match attr.ArgExpr with | SynExpr.Const (SynConst.Unit, _) -> Some (HttpAttribute.Query None) | SynExpr.Const (SynConst.String (s, SynStringKind.Regular, _), _) -> Some (HttpAttribute.Query (Some s)) | SynExpr.Const (a, _) -> failwithf "unrecognised constant arg to the Query attribute: %+A" a | _ -> None | "Path" | "PathAttribute" -> match attr.ArgExpr with | SynExpr.Const (SynConst.String (s, SynStringKind.Regular, _), _) -> Some (HttpAttribute.Path s) | SynExpr.Const (a, _) -> failwithf "unrecognised constant arg to the Path attribute: %+A" a | _ -> None | "Body" | "BodyAttribute" -> match attr.ArgExpr with | SynExpr.Const (SynConst.Unit, _) -> Some (HttpAttribute.Body) | SynExpr.Const (a, _) -> failwithf "unrecognised constant arg to the Body attribute: %+A" a | _ -> None | _ -> None ) ) { Attributes = attrs IsOptional = opt Id = id Type = usedType } | _ -> failwithf "expected SignatureParameter, got: %+A" ty let rec extractTypes (tupleType : SynTupleTypeSegment list) : Parameter list = match tupleType with | [] -> [] | [ SynTupleTypeSegment.Type param ] -> [ convertSigParam param ] | SynTupleTypeSegment.Type param :: SynTupleTypeSegment.Star _ :: rest -> convertSigParam param :: extractTypes rest | _ -> failwithf "Didn't have alternating type-and-star in interface member definition: %+A" tupleType let extractBasePath (attrs : SynAttributes) : SynExpr option = attrs |> List.tryPick (fun attr -> attr.Attributes |> List.tryPick (fun attr -> match attr.TypeName.AsString with | "BasePath" | "RestEase.BasePath" | "BasePathAttribute" | "RestEase.BasePathAttribute" -> Some attr.ArgExpr | _ -> None ) ) let extractBaseAddress (attrs : SynAttributes) : SynExpr option = attrs |> List.tryPick (fun attr -> attr.Attributes |> List.tryPick (fun attr -> match attr.TypeName.AsString with | "BaseAddress" | "RestEase.BaseAddress" | "BaseAddressAttribute" | "RestEase.BaseAddressAttribute" -> Some attr.ArgExpr | _ -> None ) ) let createModule (opens : SynOpenDeclTarget list) (ns : LongIdent) (interfaceType : SynTypeDefn) : SynModuleOrNamespace = let (SynTypeDefn (SynComponentInfo (attrs, _, _, interfaceName, _, _, _, _), synTypeDefnRepr, _, _, _, _)) = interfaceType let baseAddress = extractBaseAddress attrs let basePath = extractBasePath attrs let members = match synTypeDefnRepr with | SynTypeDefnRepr.ObjectModel (_kind, members, _) -> members |> List.map (fun defn -> match defn with | SynMemberDefn.AbstractSlot (slotSig, flags, _, _) -> match flags.MemberKind with | SynMemberKind.Member -> () | kind -> failwithf "Unrecognised member kind: %+A" kind if not flags.IsInstance then failwith "member was not an instance member" match slotSig with | SynValSig (attrs, SynIdent.SynIdent (ident, _), _typeParams, synType, arity, isInline, isMutable, _xmlDoc, accessibility, synExpr, _, _) -> if isInline then failwith "inline members not supported" if isMutable then failwith "mutable members not supported" match accessibility with | Some (SynAccess.Internal _) | Some (SynAccess.Private _) -> failwith "only public members are supported" | _ -> () match synExpr with | Some _ -> failwith "literal members are not supported" | None -> () let attrs = attrs |> List.collect (fun a -> a.Attributes) let arity = match arity with | SynValInfo ([ curriedArgs ], SynArgInfo ([], false, _)) -> curriedArgs | SynValInfo (curriedArgs, SynArgInfo ([], false, _)) -> failwithf "only tupled arguments are supported, but got: %+A" curriedArgs | SynValInfo (_, info) -> failwithf "only bare return values like `Task` are supported, but got: %+A" info let args, ret = match synType with | SynType.Fun (argType, Task returnType, _, _) -> argType, returnType | _ -> failwithf "Expected a return type of a generic Task; bad signature was: %+A" synType let args = match args with | SynType.SignatureParameter _ -> [ convertSigParam args ] | SynType.Tuple (false, path, _) -> extractTypes path | _ -> failwithf "Unrecognised args in interface method declaration: %+A" args let httpMethod, url = extractHttpInformation attrs let shouldEnsureSuccess = not (shouldAllowAnyStatusCode attrs) { HttpMethod = httpMethod UrlTemplate = url ReturnType = ret Arity = arity Args = args Identifier = ident EnsureSuccessHttpCode = shouldEnsureSuccess BaseAddress = baseAddress BasePath = basePath } | _ -> failwithf "Unrecognised member definition: %+A" defn ) | _ -> failwithf "Unrecognised SynTypeDefnRepr: %+A" synTypeDefnRepr let constructed = members |> List.map constructMember let docString = PreXmlDoc.Create " Module for constructing a REST client." let interfaceImpl = SynExpr.ObjExpr ( SynType.LongIdent (SynLongIdent.CreateFromLongIdent interfaceName), None, Some range0, [], constructed, [], range0, range0 ) let createFunc = SynBinding.SynBinding ( None, SynBindingKind.Normal, false, false, [], PreXmlDoc.Create " Create a REST client.", SynValData.SynValData ( None, SynValInfo.SynValInfo ( [ [ SynArgInfo.SynArgInfo ([], false, Some (Ident.Create "client")) ] ], SynArgInfo.Empty ), None ), SynPat.CreateLongIdent ( SynLongIdent.CreateString "make", [ SynPat.CreateParen ( SynPat.CreateTyped ( SynPat.CreateNamed (Ident.Create "client"), SynType.CreateLongIdent ( SynLongIdent.Create [ "System" ; "Net" ; "Http" ; "HttpClient" ] ) ) ) ] ), Some (SynBindingReturnInfo.Create (SynType.LongIdent (SynLongIdent.CreateFromLongIdent interfaceName))), interfaceImpl, range0, DebugPointAtBinding.NoneAtLet, synBindingTriviaZero false ) |> List.singleton |> SynModuleDecl.CreateLet let moduleName : LongIdent = List.last interfaceName |> fun ident -> ident.idText |> fun s -> if s.StartsWith 'I' then s.[1..] else failwithf "Expected interface type to start with 'I', but was: %s" s |> Ident.Create |> List.singleton let attribs = [ SynAttributeList.Create SynAttribute.compilationRepresentation SynAttributeList.Create (SynAttribute.RequireQualifiedAccess ()) ] let modInfo = SynComponentInfo.Create (moduleName, attributes = attribs, xmldoc = docString) SynModuleOrNamespace.CreateNamespace ( ns, decls = [ for openStatement in opens do yield SynModuleDecl.CreateOpen openStatement yield SynModuleDecl.CreateNestedModule (modInfo, [ createFunc ]) ] ) let rec extractOpens (moduleDecls : SynModuleDecl list) : SynOpenDeclTarget list = moduleDecls |> List.choose (fun moduleDecl -> match moduleDecl with | SynModuleDecl.Open (target, _) -> Some target | other -> None ) /// Myriad generator that provides an HTTP client for an interface type using RestEase annotations. [] type HttpClientGenerator () = interface IMyriadGenerator with member _.ValidInputExtensions = [ ".fs" ] member _.Generate (context : GeneratorContext) = let ast, _ = Ast.fromFilename context.InputFilename |> Async.RunSynchronously |> Array.head let types = Ast.extractTypeDefn ast let opens = match ast with | ParsedInput.ImplFile (ParsedImplFileInput (_, _, _, _, _, modules, _, _, _)) -> modules |> List.collect (fun (SynModuleOrNamespace (nsId, _, _, decls, _, _, _, _, _)) -> HttpClientGenerator.extractOpens decls ) | _ -> [] let namespaceAndTypes = types |> List.choose (fun (ns, types) -> match types |> List.filter Ast.hasAttribute with | [] -> None | types -> Some (ns, types) ) let modules = namespaceAndTypes |> List.collect (fun (ns, types) -> types |> List.map (fun interfaceType -> let clientModule = HttpClientGenerator.createModule opens ns interfaceType clientModule ) ) Output.Ast modules