729 lines
19 KiB
Go
729 lines
19 KiB
Go
// Package apigen implements go-omni's deliberately bounded go-zero-style API
|
|
// dialect. It generates Omni handlers, never go-zero or transport dependencies.
|
|
package apigen
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"git.yuntuo.hk/k2safe/go-omni/contract"
|
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"text/scanner"
|
|
)
|
|
|
|
type Spec struct {
|
|
DocumentCatalog *apierrors.Catalog
|
|
DocumentErrors *DocumentErrors
|
|
SharedRoots map[string]SharedRoot
|
|
SourcePaths map[string]string
|
|
RequestPolicy contract.RequestPolicy
|
|
DefaultResponse string
|
|
appDeclared bool
|
|
Title, Version, Service, Description string
|
|
Errors []ErrorDefinition
|
|
ErrorCatalogs []string
|
|
Auth []AuthDefinition
|
|
Responses []ResponseDefinition
|
|
Scalars []ScalarDefinition
|
|
Types []Type
|
|
Routes []Route
|
|
Sources map[string][]byte // relative to the entry API's directory
|
|
Entry string
|
|
}
|
|
type Type struct {
|
|
Name string
|
|
Fields []Field
|
|
Position string
|
|
}
|
|
|
|
// An empty Type denotes a named field composition, expanded before compilation.
|
|
type Field struct{ Name, Type, Tag, Description string }
|
|
type Route struct {
|
|
BoundMultipart bool
|
|
MaxParts int
|
|
MaxFieldBytes, MaxFileBytes int64
|
|
RequestPolicy contract.RequestPolicy
|
|
Manual, CustomBinding bool
|
|
BodyMode, Produces string
|
|
MaxBodyBytes int64
|
|
Method, Path, Request, Response, Handler, Group, Summary string
|
|
Middleware []string
|
|
Auth string
|
|
ResponseFormat string
|
|
ErrorResponseFormat string
|
|
Errors []string
|
|
Description string
|
|
Tags []string
|
|
Deprecated bool
|
|
Status int
|
|
Position string
|
|
}
|
|
type token struct {
|
|
text, position string
|
|
comment string
|
|
kind rune
|
|
space bool
|
|
}
|
|
type parser struct {
|
|
tokens []token
|
|
at int
|
|
spec *Spec
|
|
imports []string
|
|
}
|
|
|
|
func Parse(path string) (*Spec, error) { return parseWithCatalogs(path, nil) }
|
|
|
|
func parseWithCatalogs(path string, catalogs map[string][]byte) (*Spec, error) {
|
|
entry, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entry, err = filepath.EvalSymlinks(entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
root := filepath.Dir(entry)
|
|
shared, err := readSharedConfig(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
roots := shared.Roots
|
|
spec := &Spec{Version: "1.0.0", DocumentErrors: shared.Errors, SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)}
|
|
visiting, visited := map[string]bool{}, map[string]bool{}
|
|
total := 0
|
|
var visit func(string, string, int) error
|
|
visit = func(name, alias string, depth int) error {
|
|
if depth > 32 || len(visited)+len(visiting) >= 64 {
|
|
return errors.New("api: import limit exceeded")
|
|
}
|
|
real, err := filepath.EvalSymlinks(name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sourceRoot := root
|
|
if alias != "" {
|
|
sourceRoot = roots[alias].Path
|
|
}
|
|
rel, err := sourceWithin(sourceRoot, real)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if alias != "" {
|
|
rel = "@" + alias + "/" + rel
|
|
}
|
|
if filepath.Ext(real) != ".api" {
|
|
return errors.New("api: expected .api file")
|
|
}
|
|
if visiting[real] {
|
|
return fmt.Errorf("api: circular import %s", rel)
|
|
}
|
|
if visited[real] {
|
|
return nil
|
|
}
|
|
visiting[real] = true
|
|
defer delete(visiting, real)
|
|
f, err := os.Open(real)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
|
_ = f.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
total += len(data)
|
|
if len(data) > 1<<20 || total > 8<<20 {
|
|
return errors.New("api: source size limit exceeded")
|
|
}
|
|
tokens, err := lex(rel, string(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p := &parser{tokens: tokens, spec: spec}
|
|
if err = p.parse(); err != nil {
|
|
return err
|
|
}
|
|
spec.Sources[filepath.ToSlash(rel)] = data
|
|
spec.SourcePaths[filepath.ToSlash(rel)] = real
|
|
for _, imp := range p.imports {
|
|
if filepath.IsAbs(imp) || strings.Contains(imp, "\\") {
|
|
return errors.New("api: imports must use relative slash paths")
|
|
}
|
|
nextAlias := alias
|
|
next := filepath.Join(filepath.Dir(real), filepath.FromSlash(imp))
|
|
if strings.HasPrefix(imp, "@") {
|
|
var sub string
|
|
var ok bool
|
|
nextAlias, sub, ok = strings.Cut(strings.TrimPrefix(imp, "@"), "/")
|
|
declared, found := roots[nextAlias]
|
|
if !ok || !found || sub == "" || strings.HasPrefix(sub, "/") || strings.HasPrefix(sub, "../") || filepath.ToSlash(filepath.Clean(sub)) != sub {
|
|
return errors.New("api: import needs a declared @root and a clean relative path")
|
|
}
|
|
next = filepath.Join(declared.Path, filepath.FromSlash(sub))
|
|
}
|
|
if err = visit(next, nextAlias, depth+1); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
visited[real] = true
|
|
return nil
|
|
}
|
|
if err = visit(entry, "", 0); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = readDocumentCatalog(root, spec, catalogs); err != nil {
|
|
return nil, err
|
|
}
|
|
if err = validateSharedPins(spec); err != nil {
|
|
return nil, err
|
|
}
|
|
if spec.Service == "" || len(spec.Routes) == 0 {
|
|
return nil, errors.New("api: at least one service route required")
|
|
}
|
|
if spec.Title == "" {
|
|
spec.Title = spec.Service
|
|
}
|
|
return spec, nil
|
|
}
|
|
func lex(name, source string) ([]token, error) {
|
|
var s scanner.Scanner
|
|
s.Init(strings.NewReader(source))
|
|
s.Filename = name
|
|
s.Mode = scanner.ScanIdents | scanner.ScanInts | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanComments
|
|
s.Whitespace ^= 1 << '\n'
|
|
var scanErr error
|
|
s.Error = func(s *scanner.Scanner, message string) {
|
|
if scanErr == nil {
|
|
scanErr = fmt.Errorf("%s: %s", s.Position, message)
|
|
}
|
|
}
|
|
var out []token
|
|
end := 0
|
|
pending := ""
|
|
lastLine := 0
|
|
lastToken := -1
|
|
for {
|
|
kind := s.Scan()
|
|
if kind == scanner.Comment {
|
|
comment := cleanComment(s.TokenText())
|
|
if lastToken >= 0 && s.Position.Line == lastLine {
|
|
out[lastToken].comment = joinComment(out[lastToken].comment, comment)
|
|
} else {
|
|
pending = joinComment(pending, comment)
|
|
}
|
|
end = s.Position.Offset + len(s.TokenText())
|
|
continue
|
|
}
|
|
current := token{text: s.TokenText(), position: s.Position.String(), kind: kind, space: s.Position.Offset > end}
|
|
if kind != '\n' {
|
|
current.comment = pending
|
|
pending = ""
|
|
lastLine = s.Position.Line
|
|
lastToken = len(out)
|
|
}
|
|
out = append(out, current)
|
|
end = s.Position.Offset + len(s.TokenText())
|
|
if kind == scanner.EOF {
|
|
break
|
|
}
|
|
}
|
|
return out, scanErr
|
|
}
|
|
func (p *parser) peek() string { return p.tokens[p.at].text }
|
|
func (p *parser) next() token {
|
|
t := p.tokens[p.at]
|
|
if t.kind != scanner.EOF {
|
|
p.at++
|
|
}
|
|
return t
|
|
}
|
|
func (p *parser) skip() {
|
|
for p.peek() == "\n" || p.peek() == ";" {
|
|
p.next()
|
|
}
|
|
}
|
|
func (p *parser) err(message string) error {
|
|
return fmt.Errorf("%s: %s (got %q)", p.tokens[p.at].position, message, p.peek())
|
|
}
|
|
func (p *parser) expect(s string) error {
|
|
if p.peek() != s {
|
|
return p.err("expected " + strconv.Quote(s))
|
|
}
|
|
p.next()
|
|
return nil
|
|
}
|
|
func (p *parser) ident() (string, error) {
|
|
if p.tokens[p.at].kind != scanner.Ident {
|
|
return "", p.err("expected identifier")
|
|
}
|
|
return p.next().text, nil
|
|
}
|
|
func (p *parser) quoted() (string, error) {
|
|
if p.tokens[p.at].kind != scanner.String {
|
|
return "", p.err("expected quoted string")
|
|
}
|
|
return strconv.Unquote(p.next().text)
|
|
}
|
|
func (p *parser) parse() error {
|
|
server := map[string]string{}
|
|
syntaxSeen := false
|
|
for p.skip(); p.tokens[p.at].kind != scanner.EOF; p.skip() {
|
|
switch p.peek() {
|
|
case "scalar":
|
|
if err := p.parseScalar(); err != nil {
|
|
return err
|
|
}
|
|
case "syntax":
|
|
if syntaxSeen {
|
|
return p.err("duplicate syntax")
|
|
}
|
|
syntaxSeen = true
|
|
p.next()
|
|
if err := p.expect("="); err != nil {
|
|
return err
|
|
}
|
|
v, err := p.quoted()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if v != "v1" {
|
|
return p.err("only syntax v1 supported")
|
|
}
|
|
case "info":
|
|
p.next()
|
|
m, err := p.metadata()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, a := m["desc"]; a {
|
|
if _, b := m["description"]; b {
|
|
return p.err("use only one of info desc and description")
|
|
}
|
|
}
|
|
for k, v := range m {
|
|
switch k {
|
|
case "title":
|
|
p.spec.Title = v
|
|
case "version":
|
|
p.spec.Version = v
|
|
case "desc", "description":
|
|
p.spec.Description = v
|
|
case "author", "email":
|
|
default:
|
|
return p.err("unsupported info property " + k)
|
|
}
|
|
}
|
|
case "import":
|
|
p.next()
|
|
if p.peek() == "(" {
|
|
p.next()
|
|
for p.skip(); p.peek() != ")"; p.skip() {
|
|
v, err := p.quoted()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.imports = append(p.imports, v)
|
|
}
|
|
p.next()
|
|
} else {
|
|
v, err := p.quoted()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.imports = append(p.imports, v)
|
|
}
|
|
case "errorCatalog":
|
|
if err := p.parseErrorCatalog(); err != nil {
|
|
return err
|
|
}
|
|
case "error":
|
|
if err := p.parseErrors(); err != nil {
|
|
return err
|
|
}
|
|
case "auth":
|
|
if err := p.parseAuth(); err != nil {
|
|
return err
|
|
}
|
|
case "response":
|
|
if err := p.parseResponse(); err != nil {
|
|
return err
|
|
}
|
|
case "type":
|
|
p.next()
|
|
if p.peek() == "(" {
|
|
p.next()
|
|
for p.skip(); p.peek() != ")"; p.skip() {
|
|
if err := p.parseType(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
p.next()
|
|
} else {
|
|
if err := p.parseType(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case "@":
|
|
p.next()
|
|
if p.peek() == "app" {
|
|
if p.spec.appDeclared {
|
|
return p.err("duplicate @app declaration")
|
|
}
|
|
// Defaults belong to the entry contract, never an imported module.
|
|
if !strings.HasPrefix(p.tokens[p.at].position, p.spec.Entry+":") {
|
|
return p.err("@app must be declared in the entry API file")
|
|
}
|
|
p.next()
|
|
m, err := p.metadata()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(m) == 0 {
|
|
return p.err("@app requires defaultResponse or request")
|
|
}
|
|
for key, value := range m {
|
|
switch key {
|
|
case "defaultResponse":
|
|
case "request":
|
|
p.spec.RequestPolicy, err = decodeRequestPolicy(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return p.err("unsupported @app property " + key)
|
|
}
|
|
}
|
|
p.spec.DefaultResponse = m["defaultResponse"]
|
|
p.spec.appDeclared = true
|
|
continue
|
|
}
|
|
if err := p.expect("server"); err != nil {
|
|
return err
|
|
}
|
|
if len(server) != 0 {
|
|
return p.err("duplicate server block")
|
|
}
|
|
m, err := p.metadata()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
server = m
|
|
for k := range m {
|
|
if k != "prefix" && k != "group" && k != "middleware" && k != "auth" && k != "errors" && k != "response" {
|
|
return p.err("unsupported @server option " + k + "; wire authentication through named middleware")
|
|
}
|
|
}
|
|
case "service":
|
|
if err := p.parseService(server); err != nil {
|
|
return err
|
|
}
|
|
server = map[string]string{}
|
|
default:
|
|
return p.err("unsupported declaration")
|
|
}
|
|
}
|
|
if len(server) > 0 {
|
|
return p.err("@server must precede a service")
|
|
}
|
|
return nil
|
|
}
|
|
func (p *parser) metadata() (map[string]string, error) {
|
|
if err := p.expect("("); err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]string{}
|
|
for p.skip(); p.peek() != ")"; p.skip() {
|
|
key, err := p.ident()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = p.expect(":"); err != nil {
|
|
return nil, err
|
|
}
|
|
value := ""
|
|
if p.tokens[p.at].kind == scanner.String {
|
|
value, err = p.quoted()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else if p.peek() == "{" {
|
|
depth := 0
|
|
var object strings.Builder
|
|
for {
|
|
t := p.next()
|
|
if t.kind == scanner.EOF {
|
|
return nil, p.err("unterminated metadata object")
|
|
}
|
|
if t.text == "{" {
|
|
depth++
|
|
}
|
|
if t.text == "}" {
|
|
depth--
|
|
}
|
|
if t.space {
|
|
object.WriteByte(' ')
|
|
}
|
|
object.WriteString(t.text)
|
|
if depth == 0 {
|
|
break
|
|
}
|
|
}
|
|
value = object.String()
|
|
} else {
|
|
for p.peek() != "\n" && p.peek() != ";" && p.peek() != ")" && p.tokens[p.at].kind != scanner.EOF {
|
|
value += p.next().text
|
|
}
|
|
}
|
|
if value == "" {
|
|
return nil, p.err("metadata value required")
|
|
}
|
|
if _, exists := out[key]; exists {
|
|
return nil, p.err("duplicate metadata " + key)
|
|
}
|
|
out[key] = value
|
|
}
|
|
p.next()
|
|
return out, nil
|
|
}
|
|
func (p *parser) parseType() error {
|
|
pos := p.tokens[p.at].position
|
|
name, err := p.ident()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.skip()
|
|
if err = p.expect("{"); err != nil {
|
|
return err
|
|
}
|
|
typ := Type{Name: name, Position: pos}
|
|
for p.skip(); p.peek() != "}"; p.skip() {
|
|
description := p.tokens[p.at].comment
|
|
name, err := p.ident()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
expression := ""
|
|
for p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" && p.tokens[p.at].kind != scanner.RawString && p.tokens[p.at].kind != scanner.EOF {
|
|
if p.peek() == "interface" {
|
|
p.next()
|
|
if p.expect("{") != nil || p.expect("}") != nil {
|
|
return p.err("only empty interface{} is supported; use any for dynamic JSON")
|
|
}
|
|
expression += "any"
|
|
} else {
|
|
expression += p.next().text
|
|
}
|
|
}
|
|
if expression == "" {
|
|
if p.tokens[p.at].kind == scanner.RawString {
|
|
return p.err("composed types cannot carry field tags")
|
|
}
|
|
typ.Fields = append(typ.Fields, Field{Name: name, Description: description})
|
|
continue
|
|
}
|
|
if p.tokens[p.at].kind != scanner.RawString {
|
|
return p.err("field source tag required")
|
|
}
|
|
tagToken := p.next()
|
|
description = joinComment(description, tagToken.comment)
|
|
tag, err := strconv.Unquote(tagToken.text)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
typ.Fields = append(typ.Fields, Field{Name: name, Type: expression, Tag: tag, Description: description})
|
|
}
|
|
p.next()
|
|
p.spec.Types = append(p.spec.Types, typ)
|
|
return nil
|
|
}
|
|
func (p *parser) parseService(server map[string]string) error {
|
|
p.next()
|
|
name, err := p.ident()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for p.peek() == "-" {
|
|
p.next()
|
|
part, e := p.ident()
|
|
if e != nil {
|
|
return e
|
|
}
|
|
name += "-" + part
|
|
}
|
|
if p.spec.Service != "" && p.spec.Service != name {
|
|
return p.err("one service name per entry API is supported")
|
|
}
|
|
p.spec.Service = name
|
|
p.skip()
|
|
if err = p.expect("{"); err != nil {
|
|
return err
|
|
}
|
|
for p.skip(); p.peek() != "}"; p.skip() {
|
|
route := Route{ResponseFormat: server["response"], Auth: server["auth"], Errors: commaList(server["errors"]), Group: server["group"], Status: 200, Position: p.tokens[p.at].position}
|
|
if route.Group == "" {
|
|
route.Group = "api"
|
|
}
|
|
if server["middleware"] != "" {
|
|
route.Middleware = strings.Split(server["middleware"], ",")
|
|
}
|
|
seen := map[string]bool{}
|
|
for p.peek() == "@" {
|
|
p.next()
|
|
annotation, e := p.ident()
|
|
if e != nil {
|
|
return e
|
|
}
|
|
if seen[annotation] {
|
|
return p.err("duplicate route annotation")
|
|
}
|
|
seen[annotation] = true
|
|
switch annotation {
|
|
case "request":
|
|
var props map[string]string
|
|
props, e = p.metadata()
|
|
if e == nil {
|
|
route.RequestPolicy, e = parseRequestPolicy(props)
|
|
}
|
|
case "handlerMode":
|
|
mode, err := p.ident()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if mode != "manual" {
|
|
return p.err("handlerMode must be manual")
|
|
}
|
|
route.Manual = true
|
|
case "binding":
|
|
mode, err := p.ident()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if mode != "custom" {
|
|
return p.err("binding must be custom")
|
|
}
|
|
route.CustomBinding = true
|
|
case "produces":
|
|
route.Produces, e = p.quoted()
|
|
case "body":
|
|
e = p.parseBody(&route)
|
|
case "handler":
|
|
route.Handler, e = p.ident()
|
|
case "doc":
|
|
e = p.parseDoc(&route)
|
|
case "auth":
|
|
route.Auth, e = p.ident()
|
|
case "response":
|
|
var format string
|
|
format, e = p.ident()
|
|
if format == "raw" && route.ResponseFormat != "raw" {
|
|
route.ErrorResponseFormat = route.ResponseFormat
|
|
}
|
|
route.ResponseFormat = format
|
|
case "errors":
|
|
var names []string
|
|
names, e = p.names()
|
|
if len(names) == 1 && names[0] == "none" {
|
|
route.Errors = nil
|
|
} else {
|
|
route.Errors = mergeNames(route.Errors, names)
|
|
}
|
|
case "status":
|
|
route.Status, e = strconv.Atoi(p.next().text)
|
|
default:
|
|
return p.err("unsupported route annotation " + annotation)
|
|
}
|
|
if e != nil {
|
|
return e
|
|
}
|
|
p.skip()
|
|
}
|
|
if route.Handler == "" {
|
|
return p.err("@handler required")
|
|
}
|
|
route.Method, err = p.ident()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
path := ""
|
|
for p.peek() != "\n" && p.peek() != ";" && p.peek() != "(" && p.peek() != "returns" && p.peek() != "}" && p.tokens[p.at].kind != scanner.EOF {
|
|
path += p.next().text
|
|
}
|
|
route.Path = server["prefix"] + path
|
|
if p.peek() == "(" {
|
|
route.Request, err = p.routeType()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if p.peek() == "returns" {
|
|
p.next()
|
|
route.Response, err = p.routeType()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" {
|
|
return p.err("unexpected route suffix")
|
|
}
|
|
if route.Response == "" && !seen["status"] && !route.Manual && route.Produces == "" {
|
|
route.Status = 204
|
|
}
|
|
p.spec.Routes = append(p.spec.Routes, route)
|
|
}
|
|
p.next()
|
|
return nil
|
|
}
|
|
func (p *parser) routeType() (string, error) {
|
|
if err := p.expect("("); err != nil {
|
|
return "", err
|
|
}
|
|
prefix := ""
|
|
if p.peek() == "[" {
|
|
p.next()
|
|
if err := p.expect("]"); err != nil {
|
|
return "", err
|
|
}
|
|
prefix = "[]"
|
|
} else if p.peek() == "*" {
|
|
p.next()
|
|
prefix = "*"
|
|
}
|
|
name, err := p.ident()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return prefix + name, p.expect(")")
|
|
}
|
|
|
|
func joinComment(a, b string) string {
|
|
if a == "" {
|
|
return b
|
|
}
|
|
if b == "" {
|
|
return a
|
|
}
|
|
return a + "\n" + b
|
|
}
|
|
func cleanComment(s string) string {
|
|
if strings.HasPrefix(s, "//") {
|
|
return strings.TrimSpace(strings.TrimPrefix(s, "//"))
|
|
}
|
|
s = strings.TrimSuffix(strings.TrimPrefix(s, "/*"), "*/")
|
|
lines := strings.Split(s, "\n")
|
|
for i, line := range lines {
|
|
lines[i] = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "*"))
|
|
}
|
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
}
|
|
func fieldTag(f Field) string {
|
|
tags, err := contract.ParseTags(f.Tag)
|
|
if err != nil || f.Description == "" || tags["description"] != "" {
|
|
return f.Tag
|
|
}
|
|
return f.Tag + " description:" + strconv.Quote(f.Description)
|
|
}
|