ire4ever1190 / mike

The new and improved mikero web framework
35 stars 1 forks source link

swagger route #51

Open blackmius opened 5 days ago

blackmius commented 5 days ago

what about adding swagger routes?

i see it is possible with saving handlerInfo in dsl.nim

and few things to generate better doc:

  1. save proc comment to handlerInfo, i think it is already in bodyType just need to check for it
"path" -> get:
  # my comment describing what function do
  1. adding return type i am not sure how to add return type in current dsl
ire4ever1190 commented 4 days ago

I like the idea 🚀

  1. Would likely need either a pragma for parameters or have some kind of convention for documenting routes (Cause sadly there is no way to attach doc comments to a parameter) e.g.

    "/posts" -> post(body {.help: "Contains the post data blah blah".} : Json[Post]):
    # ...
    "/posts" -> post(body: Json[Post]):
    ## Comment describing the route
    ##
    ## * body: This would get parsed and attached to the body
  2. Probably deserves it's own issue but I always had an idea in the back of my mind for typed return types. Since I don't want to break compatibility with anything I'd have to squeeze it into the current DSL like (this isn't final, just playing around)

    "/posts" -> post(body): string:
    # .. create post
    return newPost.id

    Having that should give us ability for return types

  3. Small thing I thought of, exceptions. Since the compiler tracks them via {.raises: [].} we could have them automatically listed

blackmius commented 3 days ago

while i thinking of how to add supporting for return types i think come with such design

let router = newRouter()
type MyEnum = enum
  A
  B
  C
router.get("/items/:item_id") do (item_id: MyEnum): Json =

# GET /items?q=heh&short=true
router.get("/items") do (q: string, short: bool): Json =
  return %{"hello": "world"}

router.post("/items") do (item: Json[Item]): Response =
  Response(text="heh", headers={"blabla": "dsf"})

# maybe without specifying that it is json it should search up in different places
# depending on request Content-Type
# if application/json -> parse json
# if xxx-form -> parse form multipart data
# either try to search for query parameters
# and somehow add plugins for parsing other types
router.post("/items") do (item: Item): Response =
  Response(text="heh", headers={"blabla": "dsf"})

router.post("/header_test") do (myHeader: Header) =
  discard

router.post("/:path") do (path: Path): File =
  File(path)
router.post("/stream") do (): Stream =
  Stream

# and for addition this can support router merging
let subRoute = newRouter()
router.mount("/sub", subRoute)

the macro used here

macro get(router: Router, path: string, handler: untyped): untyped
macro post(router: Router, path: string, handler: untyped): untyped
macro put(router: Router, path: string, handler: untyped): untyped
blackmius commented 3 days ago

about pragmas for comments they can be also be in types definitions

import std/macros
import std/sets

type Obj = object
    name: string
    help: string
    fields: seq[(string, string)]

type Objects = seq[Obj]

proc parseTypeDeep(x: NimNode, visited: HashSet[string], objs: var Objects) =
    if x.kind == nnkTypeDef:
        var objFields = newSeq[(string, string)]()
        var fields: NimNode
        if x[2].kind == nnkRefTy:
            fields = x[2][0][2]
        else:
            fields = x[2][2]
        for field in fields:
            if field[1].kind == nnkSym:
                let fieldType = field[1].getImpl
                if fieldType.kind == nnkTypeDef:
                    parseTypeDeep(fieldType, visited, objs)
            else:
                # TODO: generics
                echo field[1].getTypeImpl.repr
            objFields.add((field[0].repr, field[1].repr))
        var help: string
        var name: string
        if x[0].kind == nnkPragmaExpr:
            name = x[0][0].repr
            for pragma in x[0][1]:
                if pragma[0].repr == "help":
                    help = $pragma[1]
        else:
            name = x[0].repr
        objs.add Obj(name: name, help: help, fields: objFields)

template help(s: string) {.pragma.}

macro parseType(x: typed) =
  var info = newSeq[Obj]()
  parseTypeDeep(x.getImpl, initHashSet[string](), info)
  echo info

type 
    Author = object
    Post {.help: "contains post data".} = object
        id {.help: "Post Identificator".}: int
        author {.help: "Post author".}: Author
        content {.help: "contains post text".}: string

parseType(Post)
@[(name: "Author", help: "", fields: @[]), (name: "Post", help: "contains post data", fields: @[("id {.help: \"Post Identificator\".}", "int"), ("author {.help: \"Post author\".}", "Author"), ("content {.help: \"contains post text\".}", "string")])]

unfortunately nim ast doesnt contain comments for types definitions, but it may be parsed manually using lineInfo and reading source files

proc countIndent(line: string): int =
    for c in line:
        if c != ' ':
            break
        result += 1

macro readObj(n: typed) =
    let x = n.getImpl
    echo x.lineInfoObj.repr
    let li = x.lineInfoObj
    let lines = readFile(li.filename).split("\n")
    let startIdent = countIndent(lines[li.line-1])
    var i = 0
    while i < lines.len - li.line and countIndent(lines[li.line+i]) > startIdent:
        i += 1
    echo lines[li.line-1..li.line+i-1]

maybe in future it will need integration with lib like this https://github.com/captainbland/nim-validation/tree/master

blackmius commented 2 days ago
proc swagerizePath(path: string): string =
  result = newStringOfCap(path.len+path.count(':')*2)
  var searchSlash = false;
  for c in path:
    if c == '/' and searchSlash:
      result &= '}'
      searchSlash = false
    if c == ':':
      result &= '{'
      searchSlash = true
    else:
      result &= c
  if searchSlash:
    result &= '}'

proc dumpInfo*(info: HandlerInfo): NimNode =
  let path = swagerizePath(info.path)
  let verbs = info.verbs
  var preParams = newSeq[JsonNode]()
  let pathParameters = block:
      var params: seq[string]
      let nodes = info.path.toNodes()
      for node in nodes:
        if node.kind in {Param, Greedy} and node.val != "":
          params &= node.val
      params
  var prebody: JsonNode = %*{"content": {}}
  for p in info.params:
    # not support
    # {p} -> get(p: Option[int])
    # option path
    # {p} -> get(p: Path[int])
    # direct specifying that its path
    if p.name in pathParameters:
      preParams &= %*{
        "name": p.name,
        "in": "path",
        "schema": {"type": p.kind.repr},
        "description": "",
        "required": true
      }
    elif p.kind.kind == nnkBracketExpr:
      if $(p.kind[0]) in @["Query", "Header", "Cookie"]:
        var inn: string
        if $(p.kind[0]) == "Query":
          # not supported content
          # objects as form or json
          inn = "query"
        if $(p.kind[0]) == "Header":
          inn = "header"
        if $(p.kind[0]) == "Cookie":
          inn = "cookie"
        var t: string = p.kind[1].repr
        var required: bool = true
        if p.kind[1].kind == nnkBracketExpr and $(p.kind[1][0]) == "Option":
          t = p.kind[1][1].repr
          required = false
        preParams &= %*{
          "name": p.name,
            "in": inn,
            "schema": {"type": t},
            "description": "",
            "required": required
          }
      if $(p.kind[0]) in @["Json"]:
        prebody["content"]["application/json"] = %*{
          "schema": {
            "$ref": "#/components/schemas/" & p.kind[1].repr
          }
        }
  let params = $ %preParams
  let body = $prebody
  return quote do:
    if not mikeRoutesInfo.contains(`path`):
      mikeRoutesInfo[`path`] = JsonNode(kind: JObject)
    for verb in `verbs`:
      var spec = JsonNode(kind: JObject)
      mikeRoutesInfo[`path`][($verb).toLowerAscii()] = spec
      spec["tags"] = %*{}
      spec["parameters"] = parseJson(`params`)
      spec["requestBody"] = parseJson(`body`)

 var
  mikeRouter = Router[Route]()
  errorHandlers: Table[cstring, AsyncHandler]
  mikeRoutesInfo* = JsonNode(kind: JObject)

macro `->`*(path: static[string], info: untyped, body: untyped): untyped =
    let info = getHandlerInfo(path, info, body)
    let handlerProc = createAsyncHandler(body, info.path, info.params)
    let infoJson = dumpInfo(info)
    result = genAst(path = info.path, verbs = info.verbs, pos = info.pos, handlerProc, infoJson=infoJson):
        addHandler(path, verbs, pos, handlerProc)
        infoJson

i managed to generate first version which generates openapi spec for my toy example https://github.com/blackmius/realworld-mike

image

blackmius commented 2 days ago

i see there one library for jester https://github.com/ThomasTJdev/jester2swagger/tree/main but it is mainly parsing source files using regexes (which is not as reliable as analyzing ast) but i dont know how to connect macros in such way that swagger enhancement could be optional

i mean all it needs is to receive HandlerInfo at compile time and inject some ast back (to make data available in runtime)

maybe make first some sort of plugin system and then make plugin for gather data for swagger

var transformers {.compileTime.} = newSeq[proc(x:NimNode): NimNode]()

macro test(x: untyped): untyped =
    echo transformers.len
    result = transformers[0](x)
    for t in transformers[1..^1]:
        result = t(result)
static:
    transformers.add(proc(x: NimNode): NimNode=
        return x)
static:
    transformers.add(proc(x: NimNode): NimNode=
        return x)

test echo "a"
ire4ever1190 commented 2 days ago

Sorry for the delayed response work has been busy, this is some cool stuff though. Hopefully I have time this weekend to properly look at integrating your code.

Shouldn't be too difficult to just store more information in a macrocache list/table, then a user can just place before the end of their routes something like

"/swagger" -> get:
  return renderSwagger()

For comments yeah having pragmas is going to have to be the way. I recently was writing something and wanted comments so was going to make a PR to Nim to expose the comments that is stored in nodes but it doesn't work in IC so I didn't put effort into it (Not that IC is really available :P), maybe NIR will change that limitation.

For annotating the parameters, I'd probably go down a route more like

type 
  SwaggerInfo = object
    kind: # Enum of path, query, header, cookie
    required: bool
    schema: # Schema object
   # ...
proc swaggerRouteInfo(x: typedesc[SomeType]): SwaggerInfo

That way custom types can change how it gets converted.

Like the thing you mentioned about parsing depending on Content-Type, make a quick issue to separately track that #52