Repository: goadesign/goa
Pull request #3644 (2025-02-03 11:44:53)

Add openapi:additionalProperties Meta
https://github.com/goadesign/goa/issues/2850

* Currently only boolean values ​​are supported.
* This is a feature that only affects OpenAPI, but we would like to consider real server validation functionality in the future.

raphael: My understanding is that `additionalProperties` should only be true for maps in Go, Go objects do not make it possible to dynamically add fields to them. Maybe we should instead always set it to `false` except for maps (which is already taken into consideration)? The [specification](https://spec.openapis.org/oas/v3.0.3#schema-object) says:

>Consistent with JSON Schema, additionalProperties defaults to true.

So it does seem like we should always generate it and set it to `false` except for maps.
tchssk: Sorry, I didn't explain it enough. This function was supposed to set `additionalProperties: false` on an object.

In this case, `bar` is sent even though it is not defined in the OpenAPI schema, but that is OK because `additionalProperties` is considered `true` by default.

### OpenAPI schema

```yaml
Payload:
    title: Payload
    type: object
    properties:
        foo:
            type: string
```

### Incoming request

```json
{"foo":"1","bar":"2"}
```

If `additionalProperties: false` is specified, this will be considered an invalid request and will cause OpenAPI validation errors in many libraries.

All of this is about OpenAPI and has nothing to do with the actual server implementation. In terms of Go `encoding/json`, if the unmarshal target contains undefined fields, they are simply ignored.
raphael: Ah great, that makes sense! Thank you for the explanation.

Patch:
diff --git a/dsl/meta.go b/dsl/meta.go
index b9451006..dc575c80 100644
--- a/dsl/meta.go
+++ b/dsl/meta.go
@@ -272,6 +272,25 @@ const DefaultProtoc = expr.DefaultProtoc
 //	    Meta("openapi:extension:x-api", `{"foo":"bar"}`)
 //	})
 //
+// - "openapi:additionalProperties" sets the OpenAPI additionalProperties field.
+// The value can be true or false. Defaults to true. Applicable to types (including
+// embedded Payload and Result definitions).
+//
+//	var Foo = Type("Foo", func() {
+//	    Attribute("name", String)
+//	    Meta("openapi:additionalProperties", "false")
+//	})
+//
+//	Payload(Bar, func() {
+//	    Attribute("name", String)
+//	    Meta("openapi:additionalProperties", "false")
+//	})
+//
+//	Result(func() {
+//	    Attribute("name", String)
+//	    Meta("openapi:additionalProperties", "false")
+//	})
+//
 // - "openapi:typename" overrides the name of the type generated in the OpenAPI specification.
 // Applicable to types (including embedded Payload and Result definitions).
 //
diff --git a/expr/http_body_types.go b/expr/http_body_types.go
index 09724a19..bd44b5b2 100644
--- a/expr/http_body_types.go
+++ b/expr/http_body_types.go
@@ -192,11 +192,16 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr {
 	}
 	appendSuffix(ut.Attribute().Type, suffix)
 
-	// Remember openapi typename for example to generate friendly OpenAPI specs.
 	if t, ok := payload.Type.(UserType); ok {
+		// Remember openapi typename for example to generate friendly OpenAPI specs.
 		if m, ok := t.Attribute().Meta["openapi:typename"]; ok {
 			ut.AttributeExpr.AddMeta("openapi:typename", m...)
 		}
+
+		// Remember additionalProperties.
+		if m, ok := t.Attribute().Meta["openapi:additionalProperties"]; ok {
+			ut.AttributeExpr.AddMeta("openapi:additionalProperties", m...)
+		}
 	}
 
 	return &AttributeExpr{
@@ -334,13 +339,18 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE
 		UID:           concat(svc.Name(), "#", name),
 	}
 
-	// Remember original type name and openapi typename for example
-	// to generate friendly OpenAPI specs.
 	if t, ok := attr.Type.(UserType); ok {
+		// Remember original type name and openapi typename for example
+		// to generate friendly OpenAPI specs.
 		userType.AttributeExpr.AddMeta("name:original", t.Name())
 		if m, ok := t.Attribute().Meta["openapi:typename"]; ok {
 			userType.AttributeExpr.AddMeta("openapi:typename", m...)
 		}
+
+		// Remember additionalProperties.
+		if m, ok := t.Attribute().Meta["openapi:additionalProperties"]; ok {
+			userType.AttributeExpr.AddMeta("openapi:additionalProperties", m...)
+		}
 	}
 
 	appendSuffix(userType.Attribute().Type, suffix)
diff --git a/http/codegen/openapi/json_schema.go b/http/codegen/openapi/json_schema.go
index e51658c3..ff25e3b4 100644
--- a/http/codegen/openapi/json_schema.go
+++ b/http/codegen/openapi/json_schema.go
@@ -486,6 +486,9 @@ func buildAttributeSchema(api *expr.APIExpr, s *Schema, at *expr.AttributeExpr)
 	s.Description = at.Description
 	s.Example = at.Example(api.ExampleGenerator)
 	s.Extensions = ExtensionsFromExpr(at.Meta)
+	if ap := AdditionalPropertiesFromExpr(at.Meta); ap != nil {
+		s.AdditionalProperties = ap
+	}
 	initAttributeValidation(s, at)
 
 	return s
@@ -593,3 +596,12 @@ func MustGenerate(meta expr.MetaExpr) bool {
 	}
 	return true
 }
+
+// AdditionalPropertiesFromExpr extracts the OpenAPI additionalProperties.
+func AdditionalPropertiesFromExpr(meta expr.MetaExpr) any {
+	m, ok := meta.Last("openapi:additionalProperties")
+	if ok && m == "false" {
+		return false
+	}
+	return nil
+}
diff --git a/http/codegen/openapi/v3/types.go b/http/codegen/openapi/v3/types.go
index 95425e58..5509dff4 100644
--- a/http/codegen/openapi/v3/types.go
+++ b/http/codegen/openapi/v3/types.go
@@ -266,6 +266,9 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi
 	s.Extensions = openapi.ExtensionsFromExpr(attr.Meta)
 
 	// Validations
+	if ap := openapi.AdditionalPropertiesFromExpr(attr.Meta); ap != nil {
+		s.AdditionalProperties = ap
+	}
 	val := attr.Validation
 	if val == nil {
 		return s


Test patch:
diff --git a/http/codegen/openapi/v2/files_test.go b/http/codegen/openapi/v2/files_test.go
index 8a5685bc..3f6d72e8 100644
--- a/http/codegen/openapi/v2/files_test.go
+++ b/http/codegen/openapi/v2/files_test.go
@@ -53,6 +53,9 @@ func TestSections(t *testing.T) {
 		{"json-prefix", testdata.JSONPrefixDSL},
 		{"json-indent", testdata.JSONIndentDSL},
 		{"json-prefix-indent", testdata.JSONPrefixIndentDSL},
+		{"additional-properties-type", testdata.AdditionalPropertiesTypeDSL},
+		{"additional-properties-payload-result", testdata.AdditionalPropertiesPayloadResultDSL},
+		{"additional-properties-embedded-payload-result", testdata.AdditionalPropertiesPayloadResultDSL},
 	}
 	for _, c := range cases {
 		t.Run(c.Name, func(t *testing.T) {
diff --git a/http/codegen/openapi/v2/testdata/TestSections/additional-properties-embedded-payload-result_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-embedded-payload-result_file0.golden
new file mode 100644
index 00000000..f45e532c
--- /dev/null
+++ b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-embedded-payload-result_file0.golden
@@ -0,0 +1,1 @@
+{"swagger":"2.0","info":{"title":"","version":"0.0.1"},"host":"goa.design","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/":{"get":{"tags":["testService"],"summary":"testEndpoint testService","operationId":"testService#testEndpoint","parameters":[{"name":"TestEndpointRequestBody","in":"body","required":true,"schema":{"$ref":"#/definitions/Payload"}}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/Result"}}},"schemes":["https"]}}},"definitions":{"Payload":{"title":"Payload","type":"object","properties":{"string":{"type":"string","example":""}},"example":{"string":""},"additionalProperties":false},"Result":{"title":"Result","type":"object","properties":{"string":{"type":"string","example":""}},"example":{"string":""},"additionalProperties":false}}}
\ No newline at end of file
diff --git a/http/codegen/openapi/v2/testdata/TestSections/additional-properties-embedded-payload-result_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-embedded-payload-result_file1.golden
new file mode 100644
index 00000000..c7883efc
--- /dev/null
+++ b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-embedded-payload-result_file1.golden
@@ -0,0 +1,54 @@
+swagger: "2.0"
+info:
+    title: ""
+    version: 0.0.1
+host: goa.design
+consumes:
+    - application/json
+    - application/xml
+    - application/gob
+produces:
+    - application/json
+    - application/xml
+    - application/gob
+paths:
+    /:
+        get:
+            tags:
+                - testService
+            summary: testEndpoint testService
+            operationId: testService#testEndpoint
+            parameters:
+                - name: TestEndpointRequestBody
+                  in: body
+                  required: true
+                  schema:
+                    $ref: '#/definitions/Payload'
+            responses:
+                "200":
+                    description: OK response.
+                    schema:
+                        $ref: '#/definitions/Result'
+            schemes:
+                - https
+definitions:
+    Payload:
+        title: Payload
+        type: object
+        properties:
+            string:
+                type: string
+                example: ""
+        example:
+            string: ""
+        additionalProperties: false
+    Result:
+        title: Result
+        type: object
+        properties:
+            string:
+                type: string
+                example: ""
+        example:
+            string: ""
+        additionalProperties: false
diff --git a/http/codegen/openapi/v2/testdata/TestSections/additional-properties-payload-result_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-payload-result_file0.golden
new file mode 100644
index 00000000..f45e532c
--- /dev/null
+++ b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-payload-result_file0.golden
@@ -0,0 +1,1 @@
+{"swagger":"2.0","info":{"title":"","version":"0.0.1"},"host":"goa.design","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/":{"get":{"tags":["testService"],"summary":"testEndpoint testService","operationId":"testService#testEndpoint","parameters":[{"name":"TestEndpointRequestBody","in":"body","required":true,"schema":{"$ref":"#/definitions/Payload"}}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/Result"}}},"schemes":["https"]}}},"definitions":{"Payload":{"title":"Payload","type":"object","properties":{"string":{"type":"string","example":""}},"example":{"string":""},"additionalProperties":false},"Result":{"title":"Result","type":"object","properties":{"string":{"type":"string","example":""}},"example":{"string":""},"additionalProperties":false}}}
\ No newline at end of file
diff --git a/http/codegen/openapi/v2/testdata/TestSections/additional-properties-payload-result_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-payload-result_file1.golden
new file mode 100644
index 00000000..c7883efc
--- /dev/null
+++ b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-payload-result_file1.golden
@@ -0,0 +1,54 @@
+swagger: "2.0"
+info:
+    title: ""
+    version: 0.0.1
+host: goa.design
+consumes:
+    - application/json
+    - application/xml
+    - application/gob
+produces:
+    - application/json
+    - application/xml
+    - application/gob
+paths:
+    /:
+        get:
+            tags:
+                - testService
+            summary: testEndpoint testService
+            operationId: testService#testEndpoint
+            parameters:
+                - name: TestEndpointRequestBody
+                  in: body
+                  required: true
+                  schema:
+                    $ref: '#/definitions/Payload'
+            responses:
+                "200":
+                    description: OK response.
+                    schema:
+                        $ref: '#/definitions/Result'
+            schemes:
+                - https
+definitions:
+    Payload:
+        title: Payload
+        type: object
+        properties:
+            string:
+                type: string
+                example: ""
+        example:
+            string: ""
+        additionalProperties: false
+    Result:
+        title: Result
+        type: object
+        properties:
+            string:
+                type: string
+                example: ""
+        example:
+            string: ""
+        additionalProperties: false
diff --git a/http/codegen/openapi/v2/testdata/TestSections/additional-properties-type_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-type_file0.golden
new file mode 100644
index 00000000..f45e532c
--- /dev/null
+++ b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-type_file0.golden
@@ -0,0 +1,1 @@
+{"swagger":"2.0","info":{"title":"","version":"0.0.1"},"host":"goa.design","consumes":["application/json","application/xml","application/gob"],"produces":["application/json","application/xml","application/gob"],"paths":{"/":{"get":{"tags":["testService"],"summary":"testEndpoint testService","operationId":"testService#testEndpoint","parameters":[{"name":"TestEndpointRequestBody","in":"body","required":true,"schema":{"$ref":"#/definitions/Payload"}}],"responses":{"200":{"description":"OK response.","schema":{"$ref":"#/definitions/Result"}}},"schemes":["https"]}}},"definitions":{"Payload":{"title":"Payload","type":"object","properties":{"string":{"type":"string","example":""}},"example":{"string":""},"additionalProperties":false},"Result":{"title":"Result","type":"object","properties":{"string":{"type":"string","example":""}},"example":{"string":""},"additionalProperties":false}}}
\ No newline at end of file
diff --git a/http/codegen/openapi/v2/testdata/TestSections/additional-properties-type_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-type_file1.golden
new file mode 100644
index 00000000..c7883efc
--- /dev/null
+++ b/http/codegen/openapi/v2/testdata/TestSections/additional-properties-type_file1.golden
@@ -0,0 +1,54 @@
+swagger: "2.0"
+info:
+    title: ""
+    version: 0.0.1
+host: goa.design
+consumes:
+    - application/json
+    - application/xml
+    - application/gob
+produces:
+    - application/json
+    - application/xml
+    - application/gob
+paths:
+    /:
+        get:
+            tags:
+                - testService
+            summary: testEndpoint testService
+            operationId: testService#testEndpoint
+            parameters:
+                - name: TestEndpointRequestBody
+                  in: body
+                  required: true
+                  schema:
+                    $ref: '#/definitions/Payload'
+            responses:
+                "200":
+                    description: OK response.
+                    schema:
+                        $ref: '#/definitions/Result'
+            schemes:
+                - https
+definitions:
+    Payload:
+        title: Payload
+        type: object
+        properties:
+            string:
+                type: string
+                example: ""
+        example:
+            string: ""
+        additionalProperties: false
+    Result:
+        title: Result
+        type: object
+        properties:
+            string:
+                type: string
+                example: ""
+        example:
+            string: ""
+        additionalProperties: false
diff --git a/http/codegen/testdata/openapi_dsls.go b/http/codegen/testdata/openapi_dsls.go
index 10507141..7990fbfc 100644
--- a/http/codegen/testdata/openapi_dsls.go
+++ b/http/codegen/testdata/openapi_dsls.go
@@ -945,3 +945,96 @@ var JSONPrefixIndentDSL = func() {
 		})
 	})
 }
+
+var AdditionalPropertiesTypeDSL = func() {
+	var PayloadT = Type("Payload", func() {
+		Attribute("string", String, func() {
+			Example("")
+		})
+		Meta("openapi:additionalProperties", "false")
+	})
+	var ResultT = Type("Result", func() {
+		Attribute("string", String, func() {
+			Example("")
+		})
+		Meta("openapi:additionalProperties", "false")
+	})
+	var _ = API("test", func() {
+		Server("test", func() {
+			Host("localhost", func() {
+				URI("https://goa.design")
+			})
+		})
+	})
+	Service("testService", func() {
+		Method("testEndpoint", func() {
+			Payload(PayloadT)
+			Result(ResultT)
+			HTTP(func() {
+				GET("/")
+			})
+		})
+	})
+}
+
+var AdditionalPropertiesPayloadResultDSL = func() {
+	var PayloadT = Type("Payload", func() {
+		Attribute("string", String, func() {
+			Example("")
+		})
+	})
+	var ResultT = Type("Result", func() {
+		Attribute("string", String, func() {
+			Example("")
+		})
+	})
+	var _ = API("test", func() {
+		Server("test", func() {
+			Host("localhost", func() {
+				URI("https://goa.design")
+			})
+		})
+	})
+	Service("testService", func() {
+		Method("testEndpoint", func() {
+			Payload(PayloadT, func() {
+				Meta("openapi:additionalProperties", "false")
+			})
+			Result(ResultT, func() {
+				Meta("openapi:additionalProperties", "false")
+			})
+			HTTP(func() {
+				GET("/")
+			})
+		})
+	})
+}
+
+var AdditionalPropertiesEmbeddedPayloadResultDSL = func() {
+	var _ = API("test", func() {
+		Server("test", func() {
+			Host("localhost", func() {
+				URI("https://goa.design")
+			})
+		})
+	})
+	Service("testService", func() {
+		Method("testEndpoint", func() {
+			Payload(func() {
+				Attribute("string", String, func() {
+					Example("")
+				})
+				Meta("openapi:additionalProperties", "false")
+			})
+			Result(func() {
+				Attribute("string", String, func() {
+					Example("")
+				})
+				Meta("openapi:additionalProperties", "false")
+			})
+			HTTP(func() {
+				GET("/")
+			})
+		})
+	})
+}