Skip to content

Commit e52dbab

Browse files
committed
feat(sdk): add type for secrets
1 parent fecc824 commit e52dbab

File tree

9 files changed

+143
-9
lines changed

9 files changed

+143
-9
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,9 @@ name: CI
22

33
on:
44
push:
5-
branches:
6-
- main
7-
- next
5+
branches: [main]
86
pull_request:
9-
branches:
10-
- main
7+
branches: [main]
118

129
permissions:
1310
contents: write

.github/workflows/generate.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ on:
1111

1212
jobs:
1313
generate:
14-
name: Generate
14+
name: Generate SDK
1515
runs-on: ubuntu-latest
1616
steps:
1717
- name: Checkout code
@@ -78,4 +78,4 @@ jobs:
7878
TARGET_REPO: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
7979
TARGET_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
8080
run: |
81-
git push "https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git" HEAD:${TARGET_REF}
81+
git push "https://x-access-token:${GH_TOKEN}@github.com/${TARGET_REPO}.git" "HEAD:${TARGET_REF}"

codegen/pkg/builder/methods.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,8 @@ func (b *Builder) getReferenceSchema(v *base.SchemaProxy) string {
385385
// formatStringType converts a string schema to a valid Go type.
386386
func formatStringType(t *base.Schema) string {
387387
switch t.Format {
388+
case "password":
389+
return "Secret"
388390
case "date-time":
389391
return "datetime.datetime"
390392
case "date":

codegen/pkg/builder/out.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@ import (
1818
type typesTemplateData struct {
1919
PackageName string
2020
Types []Writable
21+
UsesSecret bool
2122
}
2223

2324
func (b *Builder) generateResourceTypes(tag *base.Tag, schemas []*base.SchemaProxy) error {
2425
types := b.schemasToTypes(schemas)
26+
usesSecret := usesSecretType(types)
2527

2628
typesBuf := bytes.NewBuffer(nil)
2729
if err := b.templates.ExecuteTemplate(typesBuf, "types.py.tmpl", typesTemplateData{
2830
PackageName: strcase.ToSnake(tag.Name),
2931
Types: types,
32+
UsesSecret: usesSecret,
3033
}); err != nil {
3134
return err
3235
}
@@ -98,6 +101,7 @@ type resourceTemplateData struct {
98101
Params []Writable
99102
Service string
100103
Methods []*Method
104+
UsesSecret bool
101105
}
102106

103107
func (b *Builder) generateResourceFile(tagName string, paths *v3.Paths) ([]string, error) {
@@ -138,13 +142,16 @@ func (b *Builder) generateResourceFile(tagName string, paths *v3.Paths) ([]strin
138142
slog.Int("methods", len(methods)),
139143
)
140144

145+
usesSecret := usesSecretType(innerTypes)
146+
141147
serviceBuf := bytes.NewBuffer(nil)
142148
if err := b.templates.ExecuteTemplate(serviceBuf, "resource.py.tmpl", resourceTemplateData{
143149
PackageName: strcase.ToSnake(tag.Name),
144150
TypeNames: typeNames,
145151
Params: innerTypes,
146152
Service: strcase.ToCamel(tag.Name),
147153
Methods: methods,
154+
UsesSecret: usesSecret,
148155
}); err != nil {
149156
return nil, err
150157
}
@@ -201,6 +208,31 @@ func (b *Builder) generateResource(tagName string, paths *v3.Paths) error {
201208
return nil
202209
}
203210

211+
func usesSecretType(writables []Writable) bool {
212+
for _, w := range writables {
213+
if writableUsesSecret(w) {
214+
return true
215+
}
216+
}
217+
return false
218+
}
219+
220+
func writableUsesSecret(w Writable) bool {
221+
switch typed := w.(type) {
222+
case *ClassDeclaration:
223+
for _, f := range typed.Fields {
224+
if strings.Contains(f.Type, "Secret") {
225+
return true
226+
}
227+
}
228+
case *TypeAlias:
229+
if strings.Contains(typed.Type, "Secret") {
230+
return true
231+
}
232+
}
233+
return false
234+
}
235+
204236
func (b *Builder) writeClientFile(fname string, tags []string) error {
205237
f, err := os.OpenFile(fname, os.O_RDWR|os.O_CREATE|os.O_TRUNC, os.FileMode(0o755))
206238
if err != nil {

codegen/templates/resource.py.tmpl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# Code generated by `py-sdk-gen`. DO NOT EDIT.
22
from .._service import Resource, AsyncResource, HeaderTypes
33
from .._exceptions import APIError
4+
{{- if .UsesSecret }}
5+
from .._secret import Secret
6+
{{- end }}
47
{{- with .TypeNames }}
58
from .types import {{ join ", " . }}
69
{{- end }}

codegen/templates/types.py.tmpl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ from ._service import Service
33
import datetime
44
import typing
55
import pydantic
6+
{{- if .UsesSecret }}
7+
from .._secret import Secret
8+
{{- end }}
69

710

811
{{- range $type := .Types }}

sumup/_secret.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any
5+
6+
from pydantic import GetCoreSchemaHandler
7+
from pydantic_core import core_schema
8+
9+
_MASK = "***"
10+
11+
12+
@dataclass(frozen=True)
13+
class Secret:
14+
"""
15+
Secret wraps sensitive string values (e.g. passwords) so they stay masked in
16+
repr/log output while still serializing as plain strings in API requests.
17+
"""
18+
19+
_value: str
20+
21+
def __post_init__(self) -> None:
22+
if not isinstance(self._value, str):
23+
raise TypeError("Secret value must be a string")
24+
25+
def value(self) -> str:
26+
"""Return the underlying secret value."""
27+
return self._value
28+
29+
def __str__(self) -> str:
30+
return _MASK
31+
32+
def __repr__(self) -> str:
33+
return f"Secret({_MASK!r})"
34+
35+
def __bool__(self) -> bool:
36+
return bool(self._value)
37+
38+
def __hash__(self) -> int:
39+
return hash(self._value)
40+
41+
def __eq__(self, other: Any) -> bool:
42+
if isinstance(other, Secret):
43+
return self._value == other._value
44+
if isinstance(other, str):
45+
return self._value == other
46+
return NotImplemented
47+
48+
@classmethod
49+
def __get_pydantic_core_schema__(
50+
cls, _source: Any, _handler: GetCoreSchemaHandler
51+
) -> core_schema.CoreSchema:
52+
"""Allow pydantic models to accept raw strings but serialize as plain str."""
53+
54+
def validate(value: Any) -> "Secret":
55+
if isinstance(value, Secret):
56+
return value
57+
if isinstance(value, str):
58+
return cls(value)
59+
raise TypeError("Secret value must be a string")
60+
61+
def serialize(value: "Secret") -> str:
62+
return value._value
63+
64+
return core_schema.no_info_plain_validator_function(
65+
validate,
66+
serialization=core_schema.plain_serializer_function_ser_schema(
67+
serialize, when_used="always"
68+
),
69+
)

sumup/members/resource.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Code generated by `py-sdk-gen`. DO NOT EDIT.
22
from .._service import Resource, AsyncResource, HeaderTypes
33
from .._exceptions import APIError
4+
from .._secret import Secret
45
from .types import (
56
Attributes,
67
Member,
@@ -48,7 +49,7 @@ class CreateMerchantMemberBody(pydantic.BaseModel):
4849
Nickname of the member to add. Only used if `is_managed_user` is true. Used for display purposes only.
4950
"""
5051

51-
password: typing.Optional[str] = None
52+
password: typing.Optional[Secret] = None
5253
"""
5354
Password of the member to add. Only used if `is_managed_user` is true. In the case of service accounts, thepassword is not used and can not be defined by the caller.
5455
Format: password
@@ -66,7 +67,7 @@ class UpdateMerchantMemberBodyUser(pydantic.BaseModel):
6667
User's preferred name. Used for display purposes only.
6768
"""
6869

69-
password: typing.Optional[str] = None
70+
password: typing.Optional[Secret] = None
7071
"""
7172
Password of the member to add. Only used if `is_managed_user` is true.
7273
Format: password

tests/test_secret.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from sumup._secret import Secret
2+
from sumup.members.resource import CreateMerchantMemberBody
3+
4+
5+
def test_secret_masks_repr_but_preserves_value() -> None:
6+
secret = Secret("super-secret")
7+
8+
assert secret.value() == "super-secret"
9+
assert str(secret) == "***"
10+
assert repr(secret) == "Secret('***')"
11+
assert secret == "super-secret"
12+
13+
14+
def test_password_fields_use_secret_and_dump_plain_text() -> None:
15+
body = CreateMerchantMemberBody(
16+
email="user@example.com",
17+
roles=["role_manager"],
18+
is_managed_user=True,
19+
password=Secret("super-secret"),
20+
)
21+
22+
assert isinstance(body.password, Secret)
23+
dumped = body.model_dump()
24+
assert dumped["password"] == "super-secret"
25+
26+
body_repr = repr(body)
27+
assert "super-secret" not in body_repr

0 commit comments

Comments
 (0)