Repository: goadesign/goa (go)

Issue:
Meta OpenAPI tags are being ignored being added to methods
The `Meta` definition for overriding openapi tags are being ignored

from the docs
```
"openapi:tag:xxx" sets the OpenAPI object field tag xxx. Applicable to services and methods.

var _ = Service("MyService", func() {
    Method("MyMethod", func() {
        Meta("openapi:tag:Backend")
        Meta("openapi:tag:Backend:desc", "Description of Backend")
        Meta("openapi:tag:Backend:url", "http://example.com")
        Meta("openapi:tag:Backend:url:desc", "See more docs here")
        Meta("openapi:tag:Backend:extension:x-data", `{"foo":"bar"}`)
    })
})
```

design.go
```
package design

import (
	. "goa.design/goa/v3/dsl"
)

var _ = API("calc", func() {
	Title("cal")
	Description("cal")
	Server("calc", func() {
		Host("localhost", func() {
			URI("http://localhost:8000")
		})
	})
})

var _ = Service("calc stuff", func() {
	Description("calc stuff")

	Method("calc", func() {
		Meta("openapi:tag:Multiply")
		Meta("openapi:tag:Multiply:desc", "Should multiply the stuff")
		Meta("openapi:tag:Multiply:url", "https://docs.google.com")
		Meta("openapi:tag:Multiply:url:desc", "See more docs")

		Payload(func() {
			OneOf("operation", func() {
				Attribute("add", func() {
					Attribute("a", Int)
				})

				Attribute("subtract", func() {
					Attribute("b", Int)
				})

			})
			Example("add", func() {
				Description("operation to add")
				Value(Val{
					"add": Val{
						"a": 1,
						"b": 1,
					},
				})
			})
			Example("subtract", func() {
				Description("operation to subtract")
				Value(Val{
					"subtract": Val{
						"a": 1,
						"b": 1,
					},
				})
			})
		})

		Result(func() {
			Attribute("result", Int)
		})

		HTTP(func() {
			POST("/compute/quota")

			Response(StatusAccepted, func() {
				Body(func() {
					Attribute("result", Int)
				})
			})
		})
	})

	Files("/openapi.json", "./gen/http/openapi.json")
})

```

openapi3.yaml

```
openapi: 3.0.3
info:
    title: cal
    description: cal
    version: "1.0"
servers:
    - url: http://localhost:8000
paths:
    /compute/quota:
        post:
            tags:
                - calc stuff
            summary: calc calc stuff
            operationId: calc stuff#calc
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            $ref: '#/components/schemas/CalcRequestBody'
                        examples:
                            add:
                                summary: add
                                description: operation to add
                                value:
                                    add:
                                        a: 1
                                        b: 1
                            subtract:
                                summary: subtract
                                description: operation to subtract
                                value:
                                    subtract:
                                        a: 1
                                        b: 1
            responses:
                "202":
                    description: Accepted response.
                    content:
                        application/json:
                            schema:
                                $ref: '#/components/schemas/CalcStuffcalcResponseBody'
                            example:
                                result: 6825552331577586910
    /openapi.json:
        get:
            tags:
                - calc stuff
            summary: Download ./gen/http/openapi.json
            operationId: calc stuff#/openapi.json
            responses:
                "200":
                    description: File downloaded
components:
    schemas:
        CalcRequestBody:
            type: object
            properties:
                operation:
                    type: object
                    properties:
                        Type:
                            type: string
                            description: |-
                                Union type name, one of:
                                - "add"
                                - "subtract"
                            example: add
                            enum:
                                - add
                                - subtract
                        Value:
                            type: string
                            description: JSON formatted union value
                            example: '"JSON"'
                    example:
                        Type: subtract
                        Value: '"JSON"'
                    required:
                        - Type
                        - Value
            example:
                subtract:
                    a: 1
                    b: 1
        CalcStuffcalcResponseBody:
            type: object
            properties:
                result:
                    type: integer
                    example: 3793862871819669726
                    format: int64
            example:
                result: 8399553735696626949
tags:
    - name: calc stuff
      description: calc stuff

```

Pull request description:
Fix OpenAPI tags
* The Tag DSL must be called in HTTP expressions.
* The Tag DSL used in API and Service defines tags.
* The Tag DSL used in methods attaches the tags to the given method.

Fixes #3148 

Patch:
diff --git a/dsl/meta.go b/dsl/meta.go
index a7fd6e0a..f94c51e3 100644
--- a/dsl/meta.go
+++ b/dsl/meta.go
@@ -20,15 +20,15 @@ import (
 // services for which to generate the struct. The struct is generated for all
 // services if left empty.
 //
-//    package design
+//	package design
 //
-//    var _ = Service("service1", func() { ... })
-//    var _ = Service("service2", func() { ... })
+//	var _ = Service("service1", func() { ... })
+//	var _ = Service("service2", func() { ... })
 //
-//    var Unused = Type("Unused", func() {
-//        Attribute("name", String)
-//        Meta("type:generate:force", "service1", "service2")
-//    })
+//	var Unused = Type("Unused", func() {
+//	    Attribute("name", String)
+//	    Meta("type:generate:force", "service1", "service2")
+//	})
 //
 // - "struct:error:name" DEPRECATED, use ErrorName instead.
 //
@@ -43,21 +43,21 @@ import (
 // Go type declaration for MyType to live in the file gen/types/mytype.go with
 // the package name "types":
 //
-//    package design
+//	package design
 //
-//    var MyType = Type("MyType", func() {
-//        Attribute("name")
-//        Meta("struct:pkg:path", "types")
-//    })
+//	var MyType = Type("MyType", func() {
+//	    Attribute("name")
+//	    Meta("struct:pkg:path", "types")
+//	})
 //
 // - "struct:field:name" overrides the Go struct field name generated by default
 // by goa. Applicable to attributes only.
 //
-//    var MyType = Type("MyType", func() {
-//        Attribute("ssn", String, "User SSN", func() {
-//            Meta("struct:field:name", "SSN")
-//        })
-//    })
+//	var MyType = Type("MyType", func() {
+//	    Attribute("ssn", String, "User SSN", func() {
+//	        Meta("struct:field:name", "SSN")
+//	    })
+//	})
 //
 // - "struct:field:type" overrides the Go struct field type specified in the
 // design, with one caveat; if the type would have been a pointer (such as its
@@ -66,59 +66,59 @@ import (
 // parameter, if needed.  If the default imported package name conflicts with
 // another, you can override that as well with the third parameter.
 //
-//    var MyType = Type("BigOleMessage", func() {
-//        Attribute("type", String, "Type of big payload")
-//        Attribute("bigPayload", String, "Don't parse it if you don't have to",func() {
-//            Meta("struct:field:type","json.RawMessage","encoding/json")
-//         })
-//         Attribute("id", String, func() {
-//             Meta("struct:field:type","bison.ObjectId", "github.com/globalsign/mgo/bson", "bison")
-//         })
-//    })
+//	var MyType = Type("BigOleMessage", func() {
+//	    Attribute("type", String, "Type of big payload")
+//	    Attribute("bigPayload", String, "Don't parse it if you don't have to",func() {
+//	        Meta("struct:field:type","json.RawMessage","encoding/json")
+//	     })
+//	     Attribute("id", String, func() {
+//	         Meta("struct:field:type","bison.ObjectId", "github.com/globalsign/mgo/bson", "bison")
+//	     })
+//	})
 //
 // - "struct:field:proto" overrides the generated protobuf field type. If the
 // type is defined in a separate proto file, the last three elements define the
 // proto file import path, Go type name and Go import path respectively.
 //
-//    var Timestamp = Type("Timestamp", func() {
-//        Description("Google timestamp compatible design")
-//        Field(1, "seconds", Int64, "Unix timestamp in seconds", func() {
-//            Meta("struct:field:proto", "int64") // Goa generates sint64 by default
-//        })
-//        Field(2, "nanos", Int32, "Unix timestamp in nanoseconds", func() {
-//            Meta("struct:field:proto", "int32") // Goa generates sint32 by default
-//        })
-//    })
-//
-//    var MyType = Type("MyType", func() {
-//        Field(1, "created_at", Timestamp, func() {
-//            Meta("struct:field:proto", "google.protobuf.Timestamp", "google/protobuf/timestamp.proto", "Timestamp", "google.golang.org/protobuf/types/known/timestamppb")
-//        })
-//    })
+//	var Timestamp = Type("Timestamp", func() {
+//	    Description("Google timestamp compatible design")
+//	    Field(1, "seconds", Int64, "Unix timestamp in seconds", func() {
+//	        Meta("struct:field:proto", "int64") // Goa generates sint64 by default
+//	    })
+//	    Field(2, "nanos", Int32, "Unix timestamp in nanoseconds", func() {
+//	        Meta("struct:field:proto", "int32") // Goa generates sint32 by default
+//	    })
+//	})
+//
+//	var MyType = Type("MyType", func() {
+//	    Field(1, "created_at", Timestamp, func() {
+//	        Meta("struct:field:proto", "google.protobuf.Timestamp", "google/protobuf/timestamp.proto", "Timestamp", "google.golang.org/protobuf/types/known/timestamppb")
+//	    })
+//	})
 //
 // - "struct:tag:xxx" sets a generated Go struct field tag and overrides tags
 // that Goa would otherwise set. If the metadata value is a slice then the
 // strings are joined with the space character as separator. Applicable to
 // attributes only.
 //
-//    var MyType = Type("MyType", func() {
-//        Attribute("ssn", String, "User SSN", func() {
-//            Meta("struct:tag:json", "SSN,omitempty")
-//            Meta("struct:tag:xml", "SSN,omitempty")
-//        })
-//    })
+//	var MyType = Type("MyType", func() {
+//	    Attribute("ssn", String, "User SSN", func() {
+//	        Meta("struct:tag:json", "SSN,omitempty")
+//	        Meta("struct:tag:xml", "SSN,omitempty")
+//	    })
+//	})
 //
 // - "protoc:include" provides the list of import paths used to invoke protoc.
 // Applicable to API and service definitions only. If used on an API definition
 // the include paths are used for all services.
 //
-//    var _ = API("myapi", func() {
-//        Meta("protoc:include", "/usr/include", "/usr/local/include")
-//    })
+//	var _ = API("myapi", func() {
+//	    Meta("protoc:include", "/usr/include", "/usr/local/include")
+//	})
 //
-//    var _ = Service("service1", func() {
-//        Meta("protoc:include", "/usr/local/include/google/protobuf")
-//    })
+//	var _ = Service("service1", func() {
+//	    Meta("protoc:include", "/usr/local/include/google/protobuf")
+//	})
 //
 // - "swagger:generate" DEPRECATED, use "openapi:generate" instead.
 //
@@ -126,9 +126,9 @@ import (
 // generated. Defaults to true. Applicable to services, methods and file
 // servers.
 //
-//    var _ = Service("MyService", func() {
-//        Meta("openapi:generate", "false")
-//    })
+//	var _ = Service("MyService", func() {
+//	    Meta("openapi:generate", "false")
+//	})
 //
 // - "swagger:summary" DEPRECATED, use "openapi:summary" instead
 //
@@ -136,23 +136,23 @@ import (
 // value "{path}" is replaced with the method HTTP path. Applicable to methods
 // or to API .
 //
-//    var _ = Service("MyService", func() {
-//        Method("MyMethod", func() {
-//               Meta("openapi:summary", "Summary of MyMethod")
-//        })
-//    })
+//	var _ = Service("MyService", func() {
+//	    Method("MyMethod", func() {
+//	           Meta("openapi:summary", "Summary of MyMethod")
+//	    })
+//	})
 //
 // - "openapi:operationId" sets the OpenAPI operationId field format. The following
 // special values will be replaced with operation-specific information:
-//  
-//   "{service}" is replaced with the name of the service
 //
-//   "{method}" is replaced with the name of the method
+//	"{service}" is replaced with the name of the service
 //
-//   "(#{routeIndex})" is replaced with the index of the path in cases where a
-//    method has more than one route associated with it. The index will never be added
-//    when only one route exists. The # character may be swapped for any content you
-//    wish to use as a spacer between the preceding content and the route index.
+//	"{method}" is replaced with the name of the method
+//
+//	"(#{routeIndex})" is replaced with the index of the path in cases where a
+//	 method has more than one route associated with it. The index will never be added
+//	 when only one route exists. The # character may be swapped for any content you
+//	 wish to use as a spacer between the preceding content and the route index.
 //
 // If you wish to specify a static operationId, omitting any of the above special values
 // will render the operationId as a literal.
@@ -160,36 +160,40 @@ import (
 // Defaults to "{service}#{method}(#{routeIndex})". Applicable to methods, services, or
 // to API.
 //
-//    var _ = Service("MyService", func() {
-//        Method("MyMethod", func() {
-//               // Generates MyService.MyMethod
-//               Meta("openapi:operationId", "{service}.{method}(.{routeIndex})")
-//        })
-//    })
+//	var _ = Service("MyService", func() {
+//	    Method("MyMethod", func() {
+//	           // Generates MyService.MyMethod
+//	           Meta("openapi:operationId", "{service}.{method}(.{routeIndex})")
+//	    })
+//	})
 //
 // - "swagger:example" DEPRECATED, use "openapi:example" instead
 //
 // - "openapi:example" specifies whether to generate random example. Defaults to
 // true. Applicable to API (applies to all attributes) or individual attributes.
 //
-//    var _ = API("MyAPI", func() {
-//        Meta("openapi:example", "false")
-//    })
+//	var _ = API("MyAPI", func() {
+//	    Meta("openapi:example", "false")
+//	})
 //
 // - "swagger:tag:xxx" DEPRECATED, use "openapi:tag:xxx" instead
 //
 // - "openapi:tag:xxx" sets the OpenAPI object field tag xxx. Applicable to
-// services and methods.
-//
-//    var _ = Service("MyService", func() {
-//        Method("MyMethod", func() {
-//            Meta("openapi:tag:Backend")
-//            Meta("openapi:tag:Backend:desc", "Description of Backend")
-//            Meta("openapi:tag:Backend:url", "http://example.com")
-//            Meta("openapi:tag:Backend:url:desc", "See more docs here")
-//            Meta("openapi:tag:Backend:extension:x-data", `{"foo":"bar"}`)
-//        })
-//    })
+// HTTP services and methods. Tags are defined on services and used by methods.
+//
+//	var _ = Service("MyService", func() {
+//	    HTTP(func() {
+//	    	Meta("openapi:tag:Backend:desc", "Description of Backend")
+//	    	Meta("openapi:tag:Backend:url", "http://example.com")
+//	    	Meta("openapi:tag:Backend:url:desc", "See more docs here")
+//	    	Meta("openapi:tag:Backend:extension:x-data", `{"foo":"bar"}`)
+//	    })
+//	    Method("MyMethod", func() {
+//	        HTTP(func() {
+//	     	   Meta("openapi:tag:Backend")
+//	    	})
+//	    })
+//	})
 //
 // - "swagger:extension:xxx" DEPRECATED, use "openapi:extension:xxx" instead
 //
@@ -200,10 +204,9 @@ import (
 // response object) and Security (OpenAPI security-scheme object). See
 // https://github.com/OAI/OpenAPI-Specification/blob/master/guidelines/EXTENSIONS.md.
 //
-//    var _ = API("MyAPI", func() {
-//        Meta("openapi:extension:x-api", `{"foo":"bar"}`)
-//    })
-//
+//	var _ = API("MyAPI", func() {
+//	    Meta("openapi:extension:x-api", `{"foo":"bar"}`)
+//	})
 func Meta(name string, value ...string) {
 	appendMeta := func(meta expr.MetaExpr, name string, value ...string) expr.MetaExpr {
 		if meta == nil {
diff --git a/http/codegen/openapi/tags.go b/http/codegen/openapi/tags.go
index 3b2b41f6..fb4d7c80 100644
--- a/http/codegen/openapi/tags.go
+++ b/http/codegen/openapi/tags.go
@@ -1,7 +1,6 @@
 package openapi
 
 import (
-	"fmt"
 	"sort"
 	"strings"
 
@@ -30,44 +29,48 @@ func TagsFromExpr(mdata expr.MetaExpr) (tags []*Tag) {
 	}
 	sort.Strings(keys)
 	for _, key := range keys {
-		chunks := strings.Split(key, ":")
-		if len(chunks) != 3 {
+		chunks := strings.SplitN(key, ":", 4)
+		if len(chunks) < 3 {
 			continue
 		}
 		if (chunks[0] != "swagger" && chunks[0] != "openapi") || chunks[1] != "tag" {
 			continue
 		}
 
-		tag := &Tag{Name: chunks[2]}
-
-		mdata[key] = mdata[fmt.Sprintf("%s:desc", key)]
-		if len(mdata[key]) != 0 {
-			tag.Description = mdata[key][0]
+		name := chunks[2]
+		var tag *Tag
+		for _, t := range tags {
+			if t.Name == name {
+				tag = t
+				break
+			}
 		}
-
-		hasDocs := false
-		docs := &ExternalDocs{}
-
-		mdata[key] = mdata[fmt.Sprintf("%s:url", key)]
-		if len(mdata[key]) != 0 {
-			docs.URL = mdata[key][0]
-			hasDocs = true
+		if tag == nil {
+			tag = &Tag{Name: chunks[2]}
+			tags = append(tags, tag)
 		}
-
-		mdata[key] = mdata[fmt.Sprintf("%s:url:desc", key)]
-		if len(mdata[key]) != 0 {
-			docs.Description = mdata[key][0]
-			hasDocs = true
+		if len(chunks) == 4 {
+			switch chunks[3] {
+			case "desc":
+				tag.Description = mdata[key][0]
+			case "url":
+				if tag.ExternalDocs == nil {
+					tag.ExternalDocs = &ExternalDocs{}
+				}
+				tag.ExternalDocs.URL = mdata[key][0]
+			case "url:desc":
+				if tag.ExternalDocs == nil {
+					tag.ExternalDocs = &ExternalDocs{}
+				}
+				tag.ExternalDocs.Description = mdata[key][0]
+			default:
+				idx := strings.Index(key, "extension:")
+				if idx == -1 {
+					continue
+				}
+				tag.Extensions = extensionsFromExprWithPrefix(mdata, key[:idx+10])
+			}
 		}
-
-		if hasDocs {
-			tag.ExternalDocs = docs
-		}
-
-		extensionsPrefix := fmt.Sprintf("%s:extension:", key)
-		tag.Extensions = extensionsFromExprWithPrefix(mdata, extensionsPrefix)
-
-		tags = append(tags, tag)
 	}
 
 	return
@@ -75,12 +78,10 @@ func TagsFromExpr(mdata expr.MetaExpr) (tags []*Tag) {
 
 // TagNamesFromExpr computes the names of the OpenAPI tags specified in the
 // given metadata expressions.
-func TagNamesFromExpr(mdatas ...expr.MetaExpr) (tagNames []string) {
-	for _, mdata := range mdatas {
-		tags := TagsFromExpr(mdata)
-		for _, tag := range tags {
-			tagNames = append(tagNames, tag.Name)
-		}
+func TagNamesFromExpr(mdata expr.MetaExpr) (tagNames []string) {
+	tags := TagsFromExpr(mdata)
+	for _, tag := range tags {
+		tagNames = append(tagNames, tag.Name)
 	}
 	return
 }
diff --git a/http/codegen/openapi/v2/builder.go b/http/codegen/openapi/v2/builder.go
index 8da8b993..25b23d98 100644
--- a/http/codegen/openapi/v2/builder.go
+++ b/http/codegen/openapi/v2/builder.go
@@ -474,7 +474,7 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer
 			}
 		}
 
-		tagNames := openapi.TagNamesFromExpr(fs.Service.Meta, fs.Meta)
+		tagNames := openapi.TagNamesFromExpr(fs.Meta)
 		if len(tagNames) == 0 {
 			// By default tag with service name
 			tagNames = []string{fs.Service.Name()}
@@ -510,7 +510,7 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer
 func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr.RouteExpr, basePath string) {
 	endpoint := route.Endpoint
 
-	tagNames := openapi.TagNamesFromExpr(endpoint.Service.Meta, endpoint.Meta)
+	tagNames := openapi.TagNamesFromExpr(endpoint.Meta)
 	if len(tagNames) == 0 {
 		// By default tag with service name
 		tagNames = []string{route.Endpoint.Service.Name()}
diff --git a/http/codegen/openapi/v3/builder.go b/http/codegen/openapi/v3/builder.go
index b2aead15..41eac62e 100644
--- a/http/codegen/openapi/v3/builder.go
+++ b/http/codegen/openapi/v3/builder.go
@@ -299,7 +299,7 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand
 	// tag names
 	var tagNames []string
 	{
-		tagNames = openapi.TagNamesFromExpr(svc.Meta, e.Meta)
+		tagNames = openapi.TagNamesFromExpr(e.Meta)
 		if len(tagNames) == 0 {
 			// By default tag with service name
 			tagNames = []string{r.Endpoint.Service.Name()}
@@ -405,7 +405,7 @@ func buildFileServerOperation(key string, fs *expr.HTTPFileServerExpr, api *expr
 	// tag names
 	var tagNames []string
 	{
-		tagNames = openapi.TagNamesFromExpr(svc.Meta, fs.Meta)
+		tagNames = openapi.TagNamesFromExpr(fs.Meta)
 		if len(tagNames) == 0 {
 			// By default tag with service name
 			tagNames = []string{svc.Name()}
@@ -608,11 +608,10 @@ func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme {
 
 // buildTags builds the OpenAPI Tag object from the API expression.
 func buildTags(api *expr.APIExpr) []*openapi.Tag {
-	// if a tag with same name is defined in API, Service, and endpoint
-	// Meta expressions then the definition in endpoint Meta expression
-	// takes highest precedence followed by Service and API.
-
 	m := make(map[string]*openapi.Tag)
+	for _, t := range openapi.TagsFromExpr(api.Meta) {
+		m[t.Name] = t
+	}
 	for _, s := range api.HTTP.Services {
 		if !mustGenerate(s.Meta) || !mustGenerate(s.ServiceExpr.Meta) {
 			continue
@@ -620,14 +619,6 @@ func buildTags(api *expr.APIExpr) []*openapi.Tag {
 		for _, t := range openapi.TagsFromExpr(s.Meta) {
 			m[t.Name] = t
 		}
-		for _, e := range s.HTTPEndpoints {
-			if !mustGenerate(e.Meta) || !mustGenerate(e.MethodExpr.Meta) {
-				continue
-			}
-			for _, t := range openapi.TagsFromExpr(e.Meta) {
-				m[t.Name] = t
-			}
-		}
 	}
 
 	// sort tag names alphabetically
@@ -650,7 +641,10 @@ func buildTags(api *expr.APIExpr) []*openapi.Tag {
 				if !mustGenerate(s.Meta) || !mustGenerate(s.ServiceExpr.Meta) {
 					continue
 				}
-				tags = append(tags, &openapi.Tag{Name: s.Name(), Description: s.Description()})
+				tags = append(tags, &openapi.Tag{
+					Name:        s.Name(),
+					Description: s.Description(),
+				})
 			}
 		}
 	}


Test patch:
diff --git a/http/codegen/openapi/v3/testdata/golden/endpoint_file0.golden b/http/codegen/openapi/v3/testdata/golden/endpoint_file0.golden
index 0fb0803d..737715cc 100644
--- a/http/codegen/openapi/v3/testdata/golden/endpoint_file0.golden
+++ b/http/codegen/openapi/v3/testdata/golden/endpoint_file0.golden
@@ -1,1 +1,1 @@
-{"openapi":"3.0.3","info":{"title":"Goa API","version":"1.0","x-test-api":"API"},"servers":[{"url":"https://goa.design"}],"paths":{"/":{"post":{"operationId":"testService#testEndpoint","requestBody":{"content":{"application/json":{"example":{"string":""},"schema":{"$ref":"#/components/schemas/TestEndpointRequestBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"example":{"string":""},"schema":{"$ref":"#/components/schemas/TestEndpointRequestBody"}}},"description":"OK response."}},"summary":"testEndpoint testService","tags":["testService"],"x-test-operation":"Operation"},"x-test-foo":"bar"}},"components":{"schemas":{"TestEndpointRequestBody":{"type":"object","properties":{"string":{"example":"","type":"string","x-test-schema":"Payload"}},"example":{"string":""}}}},"tags":[{"name":"testService"}]}
\ No newline at end of file
+{"openapi":"3.0.3","info":{"title":"Goa API","version":"1.0","x-test-api":"API"},"servers":[{"url":"https://goa.design"}],"paths":{"/":{"post":{"operationId":"testService#testEndpoint","requestBody":{"content":{"application/json":{"example":{"string":""},"schema":{"$ref":"#/components/schemas/TestEndpointRequestBody"}}},"required":true},"responses":{"200":{"content":{"application/json":{"example":{"string":""},"schema":{"$ref":"#/components/schemas/TestEndpointRequestBody"}}},"description":"OK response."}},"summary":"testEndpoint testService","tags":["testService"],"x-test-operation":"Operation"},"x-test-foo":"bar"}},"components":{"schemas":{"TestEndpointRequestBody":{"type":"object","properties":{"string":{"example":"","type":"string","x-test-schema":"Payload"}},"example":{"string":""}}}},"tags":[{"description":"Description of Backend","externalDocs":{"description":"See more docs here","url":"http://example.com"},"name":"Backend","x-data":{"foo":"bar"}}]}
\ No newline at end of file
diff --git a/http/codegen/openapi/v3/testdata/golden/endpoint_file1.golden b/http/codegen/openapi/v3/testdata/golden/endpoint_file1.golden
index ef056b70..942a417f 100644
--- a/http/codegen/openapi/v3/testdata/golden/endpoint_file1.golden
+++ b/http/codegen/openapi/v3/testdata/golden/endpoint_file1.golden
@@ -43,4 +43,10 @@ components:
             example:
                 string: ""
 tags:
-    - name: testService
+    - description: Description of Backend
+      externalDocs:
+        description: See more docs here
+        url: http://example.com
+      name: Backend
+      x-data:
+        foo: bar
diff --git a/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden
index cf42983e..0eb0efd1 100644
--- a/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden
+++ b/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden
@@ -1,1 +1,1 @@
-{"openapi":"3.0.3","info":{"title":"Goa API","version":"1.0"},"servers":[{"url":"http://localhost:80","description":"Default server for test api"}],"paths":{"/{int_map}":{"post":{"tags":["Service","Endpoint","Service"],"summary":"test endpoint test service","operationId":"test service#test endpoint","parameters":[{"name":"int_map","in":"path","required":true,"schema":{"type":"integer","example":9176544974339886224,"format":"int64"},"example":1933576090881074823}],"responses":{"204":{"description":"No Content response."}}}}},"components":{},"tags":[{"name":"Endpoint","description":"Endpoint description","externalDocs":{"url":"Endpoint URL"}},{"name":"Service","description":"Overwritten service description"}]}
\ No newline at end of file
+{"openapi":"3.0.3","info":{"title":"Goa API","version":"1.0"},"servers":[{"url":"http://localhost:80","description":"Default server for test api"}],"paths":{"/{int_map}":{"post":{"tags":["SomeTag"],"summary":"test endpoint test service","operationId":"test service#test endpoint","parameters":[{"name":"int_map","in":"path","required":true,"schema":{"type":"integer","example":9176544974339886224,"format":"int64"},"example":1933576090881074823}],"responses":{"204":{"description":"No Content response."}}}}},"components":{},"tags":[{"name":"AnotherTag","description":"Endpoint description","externalDocs":{"url":"Endpoint URL"}},{"name":"SomeTag","description":"Endpoint description","externalDocs":{"url":"Endpoint URL"}}]}
\ No newline at end of file
diff --git a/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden
index 7688fe62..b9ccf5b3 100644
--- a/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden
+++ b/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden
@@ -9,9 +9,7 @@ paths:
     /{int_map}:
         post:
             tags:
-                - Service
-                - Endpoint
-                - Service
+                - SomeTag
             summary: test endpoint test service
             operationId: test service#test endpoint
             parameters:
@@ -28,10 +26,13 @@ paths:
                     description: No Content response.
 components: {}
 tags:
-    - name: Endpoint
+    - name: AnotherTag
+      description: Endpoint description
+      externalDocs:
+        description: ""
+        url: Endpoint URL
+    - name: SomeTag
       description: Endpoint description
       externalDocs:
         description: ""
         url: Endpoint URL
-    - name: Service
-      description: Overwritten service description
diff --git a/http/codegen/testdata/openapi_dsls.go b/http/codegen/testdata/openapi_dsls.go
index fbb82ea5..340e91b3 100644
--- a/http/codegen/testdata/openapi_dsls.go
+++ b/http/codegen/testdata/openapi_dsls.go
@@ -536,19 +536,17 @@ var PathWithWildcardDSL = func() {
 var WithTagsDSL = func() {
 	Service("test service", func() {
 		HTTP(func() {
-			Meta("openapi:tag:Service")
-			Meta("openapi:tag:Service:desc", "Service description")
+			Meta("openapi:tag:SomeTag:desc", "Endpoint description")
+			Meta("openapi:tag:SomeTag:url", "Endpoint URL")
+			Meta("openapi:tag:AnotherTag:desc", "Endpoint description")
+			Meta("openapi:tag:AnotherTag:url", "Endpoint URL")
 		})
 		Method("test endpoint", func() {
 			Payload(func() {
 				Attribute("int_map", Int)
 			})
 			HTTP(func() {
-				Meta("openapi:tag:Service")
-				Meta("openapi:tag:Service:desc", "Overwritten service description")
-				Meta("openapi:tag:Endpoint")
-				Meta("openapi:tag:Endpoint:desc", "Endpoint description")
-				Meta("openapi:tag:Endpoint:url", "Endpoint URL")
+				Meta("openapi:tag:SomeTag")
 				POST("/{*int_map}")
 			})
 		})
@@ -558,9 +556,7 @@ var WithTagsDSL = func() {
 			})
 			HTTP(func() {
 				Meta("openapi:generate", "false")
-				Meta("openapi:tag:AnotherEndpoint")
-				Meta("openapi:tag:AnotherEndpoint:desc", "Endpoint description")
-				Meta("openapi:tag:AnotherEndpoint:url", "Endpoint URL")
+				Meta("openapi:tag:AnotherTag")
 				POST("/{*int_map}")
 			})
 		})
@@ -568,7 +564,6 @@ var WithTagsDSL = func() {
 	Service("another test service", func() {
 		Meta("openapi:generate", "false")
 		HTTP(func() {
-			Meta("openapi:tag:AnotherService")
 			Meta("openapi:tag:AnotherService:desc", "Another service description")
 		})
 		Method("another test endpoint", func() {
@@ -576,6 +571,7 @@ var WithTagsDSL = func() {
 				Attribute("int_map", Int)
 			})
 			HTTP(func() {
+				Meta("openapi:tag:AnotherService")
 				POST("/{*int_map}")
 			})
 		})
@@ -585,19 +581,17 @@ var WithTagsDSL = func() {
 var WithTagsSwaggerDSL = func() {
 	Service("test service", func() {
 		HTTP(func() {
-			Meta("swagger:tag:Service")
-			Meta("swagger:tag:Service:desc", "Service description")
+			Meta("swagger:tag:SomeTag:desc", "Endpoint description")
+			Meta("swagger:tag:SomeTag:url", "Endpoint URL")
+			Meta("swagger:tag:AnotherTag:desc", "Endpoint description")
+			Meta("swagger:tag:AnotherTag:url", "Endpoint URL")
 		})
 		Method("test endpoint", func() {
 			Payload(func() {
 				Attribute("int_map", Int)
 			})
 			HTTP(func() {
-				Meta("swagger:tag:Service")
-				Meta("swagger:tag:Service:desc", "Overwritten service description")
-				Meta("swagger:tag:Endpoint")
-				Meta("swagger:tag:Endpoint:desc", "Endpoint description")
-				Meta("swagger:tag:Endpoint:url", "Endpoint URL")
+				Meta("swagger:tag:SomeTag")
 				POST("/{*int_map}")
 			})
 		})
@@ -607,9 +601,7 @@ var WithTagsSwaggerDSL = func() {
 			})
 			HTTP(func() {
 				Meta("swagger:generate", "false")
-				Meta("swagger:tag:AnotherEndpoint")
-				Meta("swagger:tag:AnotherEndpoint:desc", "Endpoint description")
-				Meta("swagger:tag:AnotherEndpoint:url", "Endpoint URL")
+				Meta("swagger:tag:AnotherTag")
 				POST("/{*int_map}")
 			})
 		})
@@ -617,7 +609,6 @@ var WithTagsSwaggerDSL = func() {
 	Service("another test service", func() {
 		Meta("swagger:generate", "false")
 		HTTP(func() {
-			Meta("swagger:tag:AnotherService")
 			Meta("swagger:tag:AnotherService:desc", "Another service description")
 		})
 		Method("another test endpoint", func() {
@@ -625,6 +616,7 @@ var WithTagsSwaggerDSL = func() {
 				Attribute("int_map", Int)
 			})
 			HTTP(func() {
+				Meta("swagger:tag:AnotherService")
 				POST("/{*int_map}")
 			})
 		})