| 1 |
|
package graph |
| 2 |
|
|
| 3 |
|
// This file will be automatically regenerated based on the schema, any resolver implementations |
| 4 |
|
// will be copied through when generating and any unknown code will be moved to the end. |
| 5 |
|
// Code generated by github.com/99designs/gqlgen version v0.17.36 |
| 6 |
|
|
| 7 |
|
import ( |
| 8 |
|
"context" |
| 9 |
|
"database/sql" |
| 10 |
|
"errors" |
| 11 |
|
"fmt" |
| 12 |
|
"net/url" |
| 13 |
|
"strings" |
| 14 |
|
|
| 15 |
|
sq "github.com/Masterminds/squirrel" |
| 16 |
|
"github.com/lib/pq" |
| 17 |
|
"sourcecraft.dev/bigbes/sr-ht-core/auth" |
| 18 |
|
"sourcecraft.dev/bigbes/sr-ht-core/database" |
| 19 |
|
coreerrors "sourcecraft.dev/bigbes/sr-ht-core/errors" |
| 20 |
|
model1 "sourcecraft.dev/bigbes/sr-ht-core/model" |
| 21 |
|
corewebhooks "sourcecraft.dev/bigbes/sr-ht-core/webhooks" |
| 22 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/authn" |
| 23 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/core" |
| 24 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/doc" |
| 25 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/graph/api" |
| 26 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/graph/model" |
| 27 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/search" |
| 28 |
|
"sourcecraft.dev/bigbes/sr-ht-spec/service" |
| 29 |
|
) |
| 30 |
|
|
| 31 |
|
// CreateUserWebhook is the resolver for the createUserWebhook field. |
| 32 |
3 |
func (r *mutationResolver) CreateUserWebhook(ctx context.Context, config model.UserWebhookInput) (model.WebhookSubscription, error) { |
| 33 |
3 |
// The owner gate is the whole ACL: spec is single-owner with no OAuth |
| 34 |
3 |
// clients and no scopes, so there is no per-event grant to check the way |
| 35 |
3 |
// pages.sr.ht does — either you are the owner and may manage every webhook, |
| 36 |
3 |
// or you are refused here. |
| 37 |
3 |
if err := webhookAuthorized(ctx); err != nil { |
| 38 |
3 |
return nil, err |
| 39 |
3 |
} |
| 40 |
|
|
| 41 |
|
// The schema the root resolver holds, and not core-go's server context: |
| 42 |
|
// /query is served on the anonymous router, where that context does not |
| 43 |
|
// exist. It is the same schema either way — the daemon builds exactly one. |
| 44 |
0 |
if err := corewebhooks.Validate(r.schema, config.Query); err != nil { |
| 45 |
0 |
return nil, err |
| 46 |
0 |
} |
| 47 |
|
|
| 48 |
|
// The derived AuthContext is AUTH_INTERNAL (coreauth maps the owner to it), |
| 49 |
|
// so NewAuthConfig returns an INTERNAL config: node_id set, the OAuth columns |
| 50 |
|
// nil. |
| 51 |
0 |
ac, err := corewebhooks.NewAuthConfig(ctx) |
| 52 |
0 |
if err != nil { |
| 53 |
0 |
return nil, err |
| 54 |
0 |
} |
| 55 |
|
|
| 56 |
0 |
if len(config.Events) == 0 { |
| 57 |
0 |
return nil, fmt.Errorf("must specify at least one event") |
| 58 |
0 |
} |
| 59 |
0 |
events := make([]string, len(config.Events)) |
| 60 |
0 |
for i, ev := range config.Events { |
| 61 |
0 |
events[i] = ev.String() |
| 62 |
0 |
} |
| 63 |
|
|
| 64 |
0 |
u, err := url.Parse(config.URL) |
| 65 |
0 |
if err != nil { |
| 66 |
0 |
return nil, err |
| 67 |
0 |
} else if u.Host == "" { |
| 68 |
0 |
return nil, fmt.Errorf("cannot use URL without host") |
| 69 |
0 |
} else if u.Scheme != "http" && u.Scheme != "https" { |
| 70 |
0 |
return nil, fmt.Errorf("cannot use non-HTTP or HTTPS URL") |
| 71 |
0 |
} |
| 72 |
|
|
| 73 |
0 |
var sub model.UserWebhookSubscription |
| 74 |
0 |
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error { |
| 75 |
0 |
row := tx.QueryRowContext(ctx, ` |
| 76 |
0 |
INSERT INTO gql_user_wh_sub ( |
| 77 |
0 |
created, events, url, query, |
| 78 |
0 |
auth_method, |
| 79 |
0 |
token_hash, grants, client_id, expires, |
| 80 |
0 |
node_id, |
| 81 |
0 |
user_id |
| 82 |
0 |
) VALUES ( |
| 83 |
0 |
NOW() at time zone 'utc', |
| 84 |
0 |
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10 |
| 85 |
0 |
) RETURNING id, url, query, events, user_id;`, |
| 86 |
0 |
pq.Array(events), config.URL, config.Query, |
| 87 |
0 |
ac.AuthMethod, |
| 88 |
0 |
ac.TokenHash, ac.Grants, ac.ClientID, ac.Expires, // OAUTH2 |
| 89 |
0 |
ac.NodeID, // INTERNAL |
| 90 |
0 |
auth.ForContext(ctx).UserID) |
| 91 |
0 |
|
| 92 |
0 |
if err := row.Scan(&sub.ID, &sub.URL, |
| 93 |
0 |
&sub.Query, pq.Array(&sub.Events), &sub.UserID); err != nil { |
| 94 |
0 |
return err |
| 95 |
0 |
} |
| 96 |
0 |
return nil |
| 97 |
0 |
}); err != nil { |
| 98 |
0 |
return nil, err |
| 99 |
0 |
} |
| 100 |
|
|
| 101 |
0 |
return &sub, nil |
| 102 |
|
} |
| 103 |
|
|
| 104 |
|
// DeleteUserWebhook is the resolver for the deleteUserWebhook field. |
| 105 |
3 |
func (r *mutationResolver) DeleteUserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) { |
| 106 |
3 |
if err := webhookAuthorized(ctx); err != nil { |
| 107 |
3 |
return nil, err |
| 108 |
3 |
} |
| 109 |
|
|
| 110 |
0 |
filter, err := corewebhooks.FilterWebhooks(ctx) |
| 111 |
0 |
if err != nil { |
| 112 |
0 |
return nil, err |
| 113 |
0 |
} |
| 114 |
|
|
| 115 |
0 |
var sub model.UserWebhookSubscription |
| 116 |
0 |
if err := database.WithTx(ctx, nil, func(tx *sql.Tx) error { |
| 117 |
0 |
row := sq.Delete(`gql_user_wh_sub`). |
| 118 |
0 |
PlaceholderFormat(sq.Dollar). |
| 119 |
0 |
Where(sq.And{sq.Expr(`id = ?`, id), filter}). |
| 120 |
0 |
Suffix(`RETURNING id, url, query, events, user_id`). |
| 121 |
0 |
RunWith(tx). |
| 122 |
0 |
QueryRowContext(ctx) |
| 123 |
0 |
if err := row.Scan(&sub.ID, &sub.URL, |
| 124 |
0 |
&sub.Query, pq.Array(&sub.Events), &sub.UserID); err != nil { |
| 125 |
0 |
return err |
| 126 |
0 |
} |
| 127 |
0 |
return nil |
| 128 |
0 |
}); err != nil { |
| 129 |
0 |
if err == sql.ErrNoRows { |
| 130 |
0 |
return nil, fmt.Errorf("no user webhook by ID %d found for this user", id) |
| 131 |
0 |
} |
| 132 |
0 |
return nil, err |
| 133 |
|
} |
| 134 |
|
|
| 135 |
0 |
return &sub, nil |
| 136 |
|
} |
| 137 |
|
|
| 138 |
|
// Spaces is the resolver for the spaces field. |
| 139 |
4 |
func (r *projectResolver) Spaces(ctx context.Context, obj *model.Project) ([]*model.Space, error) { |
| 140 |
4 |
ref := core.ProjectRef{Owner: obj.Owner, Name: obj.Name} |
| 141 |
4 |
spaces, err := r.reader.ProjectSpaces(ctx, ref) |
| 142 |
4 |
if err != nil { |
| 143 |
0 |
return nil, err |
| 144 |
0 |
} |
| 145 |
4 |
return spaceModels(spaces), nil |
| 146 |
|
} |
| 147 |
|
|
| 148 |
|
// Spaces is the resolver for the spaces field. |
| 149 |
9 |
func (r *queryResolver) Spaces(ctx context.Context) ([]*model.Space, error) { |
| 150 |
9 |
spaces, err := r.reader.ListSpaces(ctx) |
| 151 |
9 |
if err != nil { |
| 152 |
0 |
return nil, err |
| 153 |
0 |
} |
| 154 |
9 |
return spaceModels(spaces), nil |
| 155 |
|
} |
| 156 |
|
|
| 157 |
|
// Space is the resolver for the space field. |
| 158 |
4 |
func (r *queryResolver) Space(ctx context.Context, owner string, name string) (*model.Space, error) { |
| 159 |
4 |
ref, err := spaceRef(owner, name) |
| 160 |
4 |
if err != nil { |
| 161 |
1 |
return nil, err |
| 162 |
1 |
} |
| 163 |
3 |
sp, err := r.reader.OpenSpace(ctx, ref) |
| 164 |
3 |
if err != nil { |
| 165 |
1 |
return nil, orNull(err) |
| 166 |
1 |
} |
| 167 |
2 |
return spaceModel(sp), nil |
| 168 |
|
} |
| 169 |
|
|
| 170 |
|
// Document is the resolver for the document field. |
| 171 |
14 |
func (r *queryResolver) Document(ctx context.Context, space string, id *string, path *string, rev *string) (*model.Document, error) { |
| 172 |
14 |
arc, bodies, resolved, pinned, err := r.archive(ctx, space, rev) |
| 173 |
14 |
if err != nil { |
| 174 |
6 |
return nil, orNull(err) |
| 175 |
6 |
} |
| 176 |
8 |
page, found, err := address(arc, deref(id), deref(path)) |
| 177 |
8 |
if err != nil || !found { |
| 178 |
3 |
return nil, err |
| 179 |
3 |
} |
| 180 |
5 |
body, ok := bodies[page.Path] |
| 181 |
5 |
if !ok { |
| 182 |
0 |
// The archive is built from these very bodies, so a page without one is |
| 183 |
0 |
// a broken invariant rather than a missing document. Reporting an empty |
| 184 |
0 |
// markdown field would be indistinguishable from an empty document. |
| 185 |
0 |
return nil, fmt.Errorf("document %s of %s at %s has no body", page.Path, arc.Space, resolved) |
| 186 |
0 |
} |
| 187 |
5 |
return documentModel(arc.Space, resolved, pinned, page, body), nil |
| 188 |
|
} |
| 189 |
|
|
| 190 |
|
// Documents is the resolver for the documents field. |
| 191 |
1 |
func (r *queryResolver) Documents(ctx context.Context, space string, rev *string) ([]*model.Document, error) { |
| 192 |
1 |
arc, bodies, resolved, pinned, err := r.archive(ctx, space, rev) |
| 193 |
1 |
if err != nil { |
| 194 |
0 |
return nil, err |
| 195 |
0 |
} |
| 196 |
1 |
pages := arc.All() |
| 197 |
1 |
out := make([]*model.Document, 0, len(pages)) |
| 198 |
4 |
for _, page := range pages { |
| 199 |
4 |
body, ok := bodies[page.Path] |
| 200 |
4 |
if !ok { |
| 201 |
0 |
return nil, fmt.Errorf("document %s of %s at %s has no body", page.Path, arc.Space, resolved) |
| 202 |
0 |
} |
| 203 |
4 |
out = append(out, documentModel(arc.Space, resolved, pinned, page, body)) |
| 204 |
|
} |
| 205 |
1 |
return out, nil |
| 206 |
|
} |
| 207 |
|
|
| 208 |
|
// Search is the resolver for the search field. |
| 209 |
6 |
func (r *queryResolver) Search(ctx context.Context, query string, spaces []string, limit *int, offset *int) (*model.SearchResults, error) { |
| 210 |
6 |
q := search.Query{Text: strings.TrimSpace(query)} |
| 211 |
6 |
|
| 212 |
6 |
// Filter polarity, which is the one thing here that is dangerous to get |
| 213 |
6 |
// wrong — and the reason core.SpaceFilter exists rather than a slice. |
| 214 |
6 |
// Absent means every space, which is the meta-project. A list means exactly |
| 215 |
6 |
// those spaces, and a list that is present but empty therefore selects |
| 216 |
6 |
// nothing at all: that is what an empty project's membership is, and a |
| 217 |
6 |
// client passing one through must get no hits rather than the whole corpus. |
| 218 |
6 |
// GraphQL distinguishes the two cases for free — null is not [] — so the |
| 219 |
6 |
// argument's own shape carries the polarity that used to be a convention. |
| 220 |
6 |
if spaces == nil { |
| 221 |
3 |
q.Spaces = core.EverythingFilter() |
| 222 |
3 |
} else { |
| 223 |
3 |
refs := make([]core.SpaceRef, 0, len(spaces)) |
| 224 |
3 |
for _, s := range spaces { |
| 225 |
1 |
ref, err := core.ParseSpaceRef(strings.TrimSpace(s)) |
| 226 |
1 |
if err != nil { |
| 227 |
0 |
return nil, fmt.Errorf("spaces: %w", err) |
| 228 |
0 |
} |
| 229 |
1 |
refs = append(refs, ref) |
| 230 |
|
} |
| 231 |
3 |
q.Spaces = core.SpacesFilter(refs, nil) |
| 232 |
|
} |
| 233 |
|
|
| 234 |
6 |
if limit != nil { |
| 235 |
2 |
if *limit < 0 { |
| 236 |
1 |
return nil, fmt.Errorf("limit: %d is negative", *limit) |
| 237 |
1 |
} |
| 238 |
1 |
q.Limit = *limit |
| 239 |
|
} |
| 240 |
5 |
if offset != nil { |
| 241 |
1 |
if *offset < 0 { |
| 242 |
0 |
return nil, fmt.Errorf("offset: %d is negative", *offset) |
| 243 |
0 |
} |
| 244 |
1 |
q.Offset = *offset |
| 245 |
|
} |
| 246 |
|
|
| 247 |
5 |
res, err := r.searcher.Search(ctx, q) |
| 248 |
5 |
if err != nil { |
| 249 |
0 |
return nil, err |
| 250 |
0 |
} |
| 251 |
5 |
out := &model.SearchResults{ |
| 252 |
5 |
Total: int(res.Total), |
| 253 |
5 |
Took: res.Took.String(), |
| 254 |
5 |
Hits: make([]*model.SearchHit, 0, len(res.Hits)), |
| 255 |
5 |
} |
| 256 |
5 |
for _, h := range res.Hits { |
| 257 |
3 |
out.Hits = append(out.Hits, &model.SearchHit{ |
| 258 |
3 |
Space: h.Space.String(), |
| 259 |
3 |
ID: h.ID, |
| 260 |
3 |
Rev: h.Rev, |
| 261 |
3 |
Path: h.Path, |
| 262 |
3 |
Anchor: h.Anchor, |
| 263 |
3 |
Title: h.Title, |
| 264 |
3 |
Section: h.Section, |
| 265 |
3 |
Lang: string(h.Lang), |
| 266 |
3 |
Score: h.Score, |
| 267 |
3 |
Snippet: h.Snippet, |
| 268 |
3 |
}) |
| 269 |
3 |
} |
| 270 |
5 |
return out, nil |
| 271 |
|
} |
| 272 |
|
|
| 273 |
|
// Projects is the resolver for the projects field. |
| 274 |
1 |
func (r *queryResolver) Projects(ctx context.Context) ([]*model.Project, error) { |
| 275 |
1 |
projects, err := r.reader.ListProjects(ctx) |
| 276 |
1 |
if err != nil { |
| 277 |
0 |
return nil, err |
| 278 |
0 |
} |
| 279 |
1 |
out := make([]*model.Project, 0, len(projects)) |
| 280 |
2 |
for _, p := range projects { |
| 281 |
2 |
out = append(out, projectModel(p.Ref)) |
| 282 |
2 |
} |
| 283 |
1 |
return out, nil |
| 284 |
|
} |
| 285 |
|
|
| 286 |
|
// Project is the resolver for the project field. |
| 287 |
5 |
func (r *queryResolver) Project(ctx context.Context, owner string, name string) (*model.Project, error) { |
| 288 |
5 |
ref, err := projectRef(owner, name) |
| 289 |
5 |
if err != nil { |
| 290 |
0 |
return nil, err |
| 291 |
0 |
} |
| 292 |
|
// The meta-project is an address that resolves to a filter, not a row: it |
| 293 |
|
// needs no storage, cannot be renamed or deleted, and adding a space to the |
| 294 |
|
// service adds it to the meta-project by construction. So it is answered |
| 295 |
|
// here rather than looked up — a lookup would report the one project that |
| 296 |
|
// can never be missing as missing. |
| 297 |
5 |
if ref.IsMeta() { |
| 298 |
1 |
return projectModel(ref), nil |
| 299 |
1 |
} |
| 300 |
4 |
if _, err := r.reader.GetProject(ctx, ref); err != nil { |
| 301 |
1 |
return nil, orNull(err) |
| 302 |
1 |
} |
| 303 |
3 |
return projectModel(ref), nil |
| 304 |
|
} |
| 305 |
|
|
| 306 |
|
// Proposals is the resolver for the proposals field. |
| 307 |
3 |
func (r *queryResolver) Proposals(ctx context.Context, space string, state model.ProposalState) ([]*model.Proposal, error) { |
| 308 |
3 |
ref, err := core.ParseSpaceRef(strings.TrimSpace(space)) |
| 309 |
3 |
if err != nil { |
| 310 |
0 |
return nil, fmt.Errorf("space: %w", err) |
| 311 |
0 |
} |
| 312 |
|
// GraphQL has already validated the enum, so this cannot fail over a |
| 313 |
|
// caller's spelling; it fails only if this schema and core disagree about |
| 314 |
|
// the state machine, which is worth hearing about rather than defaulting |
| 315 |
|
// past. |
| 316 |
3 |
st, err := core.ParseProposalState(strings.ToLower(string(state))) |
| 317 |
3 |
if err != nil { |
| 318 |
0 |
return nil, err |
| 319 |
0 |
} |
| 320 |
3 |
if r.proposals == nil { |
| 321 |
1 |
return nil, errors.New("proposals cannot be read: service/ exposes no proposal listing yet, " + |
| 322 |
1 |
"and nothing above it may query the database directly (the write plane is Phase 3)") |
| 323 |
1 |
} |
| 324 |
2 |
proposals, err := r.proposals.ListProposals(ctx, ref, st) |
| 325 |
2 |
if err != nil { |
| 326 |
0 |
return nil, err |
| 327 |
0 |
} |
| 328 |
2 |
out := make([]*model.Proposal, 0, len(proposals)) |
| 329 |
2 |
for _, p := range proposals { |
| 330 |
2 |
m, err := proposalModel(p) |
| 331 |
2 |
if err != nil { |
| 332 |
0 |
return nil, err |
| 333 |
0 |
} |
| 334 |
2 |
out = append(out, m) |
| 335 |
|
} |
| 336 |
2 |
return out, nil |
| 337 |
|
} |
| 338 |
|
|
| 339 |
|
// UserWebhooks is the resolver for the userWebhooks field. |
| 340 |
3 |
func (r *queryResolver) UserWebhooks(ctx context.Context, cursor *model1.Cursor) (*model.WebhookSubscriptionCursor, error) { |
| 341 |
3 |
if err := webhookAuthorized(ctx); err != nil { |
| 342 |
3 |
return nil, err |
| 343 |
3 |
} |
| 344 |
0 |
if cursor == nil { |
| 345 |
0 |
cursor = model1.NewCursor(nil) |
| 346 |
0 |
} |
| 347 |
|
|
| 348 |
0 |
filter, err := corewebhooks.FilterWebhooks(ctx) |
| 349 |
0 |
if err != nil { |
| 350 |
0 |
return nil, err |
| 351 |
0 |
} |
| 352 |
|
|
| 353 |
0 |
var subs []model.WebhookSubscription |
| 354 |
0 |
if err := database.WithTx(ctx, &sql.TxOptions{ |
| 355 |
0 |
Isolation: 0, |
| 356 |
0 |
ReadOnly: true, |
| 357 |
0 |
}, func(tx *sql.Tx) error { |
| 358 |
0 |
sub := (&model.UserWebhookSubscription{}).As(`sub`) |
| 359 |
0 |
query := database. |
| 360 |
0 |
Select(ctx, sub). |
| 361 |
0 |
From(`gql_user_wh_sub sub`). |
| 362 |
0 |
Where(filter) |
| 363 |
0 |
subs, cursor = sub.QueryWithCursor(ctx, tx, query, cursor) |
| 364 |
0 |
return nil |
| 365 |
0 |
}); err != nil { |
| 366 |
0 |
return nil, err |
| 367 |
0 |
} |
| 368 |
|
|
| 369 |
0 |
return &model.WebhookSubscriptionCursor{Results: subs, Cursor: cursor}, nil |
| 370 |
|
} |
| 371 |
|
|
| 372 |
|
// UserWebhook is the resolver for the userWebhook field. |
| 373 |
3 |
func (r *queryResolver) UserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) { |
| 374 |
3 |
if err := webhookAuthorized(ctx); err != nil { |
| 375 |
3 |
return nil, err |
| 376 |
3 |
} |
| 377 |
|
|
| 378 |
0 |
filter, err := corewebhooks.FilterWebhooks(ctx) |
| 379 |
0 |
if err != nil { |
| 380 |
0 |
return nil, err |
| 381 |
0 |
} |
| 382 |
|
|
| 383 |
0 |
var sub model.UserWebhookSubscription |
| 384 |
0 |
if err := database.WithTx(ctx, &sql.TxOptions{ |
| 385 |
0 |
Isolation: 0, |
| 386 |
0 |
ReadOnly: true, |
| 387 |
0 |
}, func(tx *sql.Tx) error { |
| 388 |
0 |
row := database. |
| 389 |
0 |
Select(ctx, &sub). |
| 390 |
0 |
From(`gql_user_wh_sub`). |
| 391 |
0 |
Where(sq.And{sq.Expr(`id = ?`, id), filter}). |
| 392 |
0 |
RunWith(tx). |
| 393 |
0 |
QueryRowContext(ctx) |
| 394 |
0 |
if err := row.Scan(database.Scan(ctx, &sub)...); err != nil { |
| 395 |
0 |
return err |
| 396 |
0 |
} |
| 397 |
0 |
return nil |
| 398 |
0 |
}); err != nil { |
| 399 |
0 |
if err == sql.ErrNoRows { |
| 400 |
0 |
return nil, fmt.Errorf("no user webhook by ID %d found for this user", id) |
| 401 |
0 |
} |
| 402 |
0 |
return nil, err |
| 403 |
|
} |
| 404 |
|
|
| 405 |
0 |
return &sub, nil |
| 406 |
|
} |
| 407 |
|
|
| 408 |
|
// Webhook is the resolver for the webhook field. |
| 409 |
1 |
func (r *queryResolver) Webhook(ctx context.Context) (model.WebhookPayload, error) { |
| 410 |
1 |
raw, err := corewebhooks.Payload(ctx) |
| 411 |
1 |
if err != nil { |
| 412 |
1 |
return nil, err |
| 413 |
1 |
} |
| 414 |
0 |
payload, ok := raw.(model.WebhookPayload) |
| 415 |
0 |
if !ok { |
| 416 |
0 |
panic("Invalid webhook payload context") |
| 417 |
|
} |
| 418 |
0 |
return payload, nil |
| 419 |
|
} |
| 420 |
|
|
| 421 |
|
// ApprovedRev is the resolver for the approvedRev field. |
| 422 |
1 |
func (r *spaceResolver) ApprovedRev(ctx context.Context, obj *model.Space) (string, error) { |
| 423 |
1 |
// A field resolver rather than a struct field: ListSpaces deliberately does |
| 424 |
1 |
// not open repositories, so resolving the approved head of every space in a |
| 425 |
1 |
// listing would make the cheapest view in the service the most expensive |
| 426 |
1 |
// one. Here it is paid for only by a query that asks for it. |
| 427 |
1 |
sp, err := r.reader.OpenSpace(ctx, core.SpaceRef{Owner: obj.Owner, Name: obj.Name}) |
| 428 |
1 |
if err != nil { |
| 429 |
0 |
return "", err |
| 430 |
0 |
} |
| 431 |
1 |
return r.reader.ResolveRev(ctx, sp, service.ApprovedRev) |
| 432 |
|
} |
| 433 |
|
|
| 434 |
|
// Deliveries is the resolver for the deliveries field. |
| 435 |
0 |
func (r *userWebhookSubscriptionResolver) Deliveries(ctx context.Context, obj *model.UserWebhookSubscription, cursor *model1.Cursor) (*model.WebhookDeliveryCursor, error) { |
| 436 |
0 |
if cursor == nil { |
| 437 |
0 |
cursor = model1.NewCursor(nil) |
| 438 |
0 |
} |
| 439 |
|
|
| 440 |
0 |
var deliveries []*model.WebhookDelivery |
| 441 |
0 |
if err := database.WithTx(ctx, &sql.TxOptions{ |
| 442 |
0 |
Isolation: 0, |
| 443 |
0 |
ReadOnly: true, |
| 444 |
0 |
}, func(tx *sql.Tx) error { |
| 445 |
0 |
d := (&model.WebhookDelivery{}). |
| 446 |
0 |
WithName(`user`). |
| 447 |
0 |
As(`delivery`) |
| 448 |
0 |
query := database. |
| 449 |
0 |
Select(ctx, d). |
| 450 |
0 |
From(`gql_user_wh_delivery delivery`). |
| 451 |
0 |
Where(`delivery.subscription_id = ?`, obj.ID) |
| 452 |
0 |
deliveries, cursor = d.QueryWithCursor(ctx, tx, query, cursor) |
| 453 |
0 |
return nil |
| 454 |
0 |
}); err != nil { |
| 455 |
0 |
return nil, err |
| 456 |
0 |
} |
| 457 |
|
|
| 458 |
0 |
return &model.WebhookDeliveryCursor{Results: deliveries, Cursor: cursor}, nil |
| 459 |
|
} |
| 460 |
|
|
| 461 |
|
// Sample is the resolver for the sample field. |
| 462 |
|
// |
| 463 |
|
// It is the one field of this schema that /query cannot answer, and it says so |
| 464 |
|
// rather than failing at a lower level with something a caller cannot read. |
| 465 |
|
// |
| 466 |
|
// The reason is core-go's, not this service's. corewebhooks.WebhookContext.Exec |
| 467 |
|
// — which is what renders a sample, by running the subscriber's own stored query |
| 468 |
|
// against a synthetic payload — reads the complexity limit off core-go's server |
| 469 |
|
// context and panics when there is none. That context is installed by |
| 470 |
|
// server.WithDefaultMiddleware on the *authenticated* router, and this endpoint |
| 471 |
|
// is on the anonymous one because it authenticates with tokens.sr.ht working |
| 472 |
|
// tokens rather than with meta's OAuth vocabulary. There is no exported way to |
| 473 |
|
// put that value on a context; the alternatives are to fork Exec into this |
| 474 |
|
// service or to let the panic surface as "internal system error", and a named |
| 475 |
|
// refusal beats both. |
| 476 |
|
// |
| 477 |
|
// Delivery is unaffected: the webhook queue's own context comes from |
| 478 |
|
// server.WithQueues, which does carry it, so a real delivery renders exactly as |
| 479 |
|
// before. |
| 480 |
0 |
func (r *userWebhookSubscriptionResolver) Sample(ctx context.Context, obj *model.UserWebhookSubscription, event model.WebhookEvent) (string, error) { |
| 481 |
0 |
switch event { |
| 482 |
|
case model.WebhookEventProposalOpened, |
| 483 |
|
model.WebhookEventProposalMerged, |
| 484 |
|
model.WebhookEventProposalRejected: |
| 485 |
0 |
default: |
| 486 |
0 |
return "", fmt.Errorf("unsupported event %s", event.String()) |
| 487 |
|
} |
| 488 |
0 |
return "", fmt.Errorf( |
| 489 |
0 |
"sample is not available on this endpoint: rendering one needs core-go's server " + |
| 490 |
0 |
"context, which exists only on the authenticated router, and spec.sr.ht serves " + |
| 491 |
0 |
"/query on the anonymous router so that a tokens.sr.ht working token is the " + |
| 492 |
0 |
"credential; subscribe and trigger the event to see a real delivery") |
| 493 |
|
} |
| 494 |
|
|
| 495 |
|
// Subscription is the resolver for the subscription field. |
| 496 |
0 |
func (r *webhookDeliveryResolver) Subscription(ctx context.Context, obj *model.WebhookDelivery) (model.WebhookSubscription, error) { |
| 497 |
0 |
if obj.Name == "" { |
| 498 |
0 |
panic("WebhookDelivery without name") |
| 499 |
|
} |
| 500 |
|
|
| 501 |
|
// XXX: This could use a loader but it's unlikely to be a bottleneck |
| 502 |
0 |
var sub model.WebhookSubscription |
| 503 |
0 |
if err := database.WithTx(ctx, &sql.TxOptions{ |
| 504 |
0 |
Isolation: 0, |
| 505 |
0 |
ReadOnly: true, |
| 506 |
0 |
}, func(tx *sql.Tx) error { |
| 507 |
0 |
// XXX: This needs some work to generalize to other kinds of webhooks |
| 508 |
0 |
var subscription interface { |
| 509 |
0 |
model.WebhookSubscription |
| 510 |
0 |
database.Model |
| 511 |
0 |
} = nil |
| 512 |
0 |
switch obj.Name { |
| 513 |
0 |
case "user": |
| 514 |
0 |
subscription = (&model.UserWebhookSubscription{}).As(`sub`) |
| 515 |
0 |
default: |
| 516 |
0 |
panic(fmt.Errorf("unknown webhook name %q", obj.Name)) |
| 517 |
|
} |
| 518 |
|
// Note: No filter needed because, if we have access to the delivery, |
| 519 |
|
// we also have access to the subscription. |
| 520 |
0 |
row := database. |
| 521 |
0 |
Select(ctx, subscription). |
| 522 |
0 |
From(`gql_`+obj.Name+`_wh_sub sub`). |
| 523 |
0 |
Where(`sub.id = ?`, obj.SubscriptionID). |
| 524 |
0 |
RunWith(tx). |
| 525 |
0 |
QueryRowContext(ctx) |
| 526 |
0 |
if err := row.Scan(database.Scan(ctx, subscription)...); err != nil { |
| 527 |
0 |
return err |
| 528 |
0 |
} |
| 529 |
0 |
sub = subscription |
| 530 |
0 |
return nil |
| 531 |
0 |
}); err != nil { |
| 532 |
0 |
return nil, err |
| 533 |
0 |
} |
| 534 |
0 |
return sub, nil |
| 535 |
|
} |
| 536 |
|
|
| 537 |
|
// Mutation returns api.MutationResolver implementation. |
| 538 |
6 |
func (r *Resolver) Mutation() api.MutationResolver { return &mutationResolver{r} } |
| 539 |
|
|
| 540 |
|
// Project returns api.ProjectResolver implementation. |
| 541 |
4 |
func (r *Resolver) Project() api.ProjectResolver { return &projectResolver{r} } |
| 542 |
|
|
| 543 |
|
// Query returns api.QueryResolver implementation. |
| 544 |
50 |
func (r *Resolver) Query() api.QueryResolver { return &queryResolver{r} } |
| 545 |
|
|
| 546 |
|
// Space returns api.SpaceResolver implementation. |
| 547 |
1 |
func (r *Resolver) Space() api.SpaceResolver { return &spaceResolver{r} } |
| 548 |
|
|
| 549 |
|
// UserWebhookSubscription returns api.UserWebhookSubscriptionResolver implementation. |
| 550 |
0 |
func (r *Resolver) UserWebhookSubscription() api.UserWebhookSubscriptionResolver { |
| 551 |
0 |
return &userWebhookSubscriptionResolver{r} |
| 552 |
0 |
} |
| 553 |
|
|
| 554 |
|
// WebhookDelivery returns api.WebhookDeliveryResolver implementation. |
| 555 |
0 |
func (r *Resolver) WebhookDelivery() api.WebhookDeliveryResolver { return &webhookDeliveryResolver{r} } |
| 556 |
|
|
| 557 |
|
type mutationResolver struct{ *Resolver } |
| 558 |
|
type projectResolver struct{ *Resolver } |
| 559 |
|
type queryResolver struct{ *Resolver } |
| 560 |
|
type spaceResolver struct{ *Resolver } |
| 561 |
|
type userWebhookSubscriptionResolver struct{ *Resolver } |
| 562 |
|
type webhookDeliveryResolver struct{ *Resolver } |
| 563 |
|
|
| 564 |
|
// !!! WARNING !!! |
| 565 |
|
// The code below was going to be deleted when updating resolvers. It has been copied here so you have |
| 566 |
|
// one last chance to move it out of harms way if you want. There are two reasons this happens: |
| 567 |
|
// - When renaming or deleting a resolver the old code will be put in here. You can safely delete |
| 568 |
|
// it when you're done. |
| 569 |
|
// - You have helper methods in this file. Move them out to keep these resolver files clean. |
| 570 |
15 |
func (r *queryResolver) archive(ctx context.Context, space string, rev *string) (*doc.Archive, map[string][]byte, string, bool, error) { |
| 571 |
15 |
ref, err := core.ParseSpaceRef(strings.TrimSpace(space)) |
| 572 |
15 |
if err != nil { |
| 573 |
0 |
return nil, nil, "", false, fmt.Errorf("space: %w", err) |
| 574 |
0 |
} |
| 575 |
15 |
want := deref(rev) |
| 576 |
15 |
if err := service.ValidateReadRev(want); err != nil { |
| 577 |
5 |
return nil, nil, "", false, fmt.Errorf("rev: %w", err) |
| 578 |
5 |
} |
| 579 |
10 |
sp, err := r.reader.OpenSpace(ctx, ref) |
| 580 |
10 |
if err != nil { |
| 581 |
0 |
return nil, nil, "", false, err |
| 582 |
0 |
} |
| 583 |
10 |
arc, bodies, err := r.reader.Archive(ctx, sp, want) |
| 584 |
10 |
if err != nil { |
| 585 |
1 |
return nil, nil, "", false, err |
| 586 |
1 |
} |
| 587 |
9 |
return arc, bodies, arc.Rev, want != service.ApprovedRev, nil |
| 588 |
|
} |
| 589 |
8 |
func address(arc *doc.Archive, id, path string) (*doc.Page, bool, error) { |
| 590 |
8 |
id = strings.TrimSpace(id) |
| 591 |
8 |
path = strings.TrimSpace(path) |
| 592 |
8 |
|
| 593 |
8 |
switch { |
| 594 |
1 |
case id == "" && path == "": |
| 595 |
1 |
return nil, false, errors.New("give either id or path: a document has to be addressed") |
| 596 |
1 |
case id != "" && path != "": |
| 597 |
1 |
return nil, false, fmt.Errorf("give either id or path, not both: %q and %q cannot both name the document wanted", id, path) |
| 598 |
|
} |
| 599 |
|
|
| 600 |
6 |
if path != "" { |
| 601 |
1 |
if err := core.ValidateDocPath(path); err != nil { |
| 602 |
0 |
return nil, false, fmt.Errorf("path: %w", err) |
| 603 |
0 |
} |
| 604 |
1 |
page, ok := arc.ByPath(path) |
| 605 |
1 |
return page, ok, nil |
| 606 |
|
} |
| 607 |
|
|
| 608 |
5 |
if page, ok := arc.Page(id); ok { |
| 609 |
4 |
return page, true, nil |
| 610 |
4 |
} |
| 611 |
|
|
| 612 |
|
// Nothing is addressed by that id. Saying only "no such document" would |
| 613 |
|
// hide the one case a caller cannot otherwise diagnose: an id two documents |
| 614 |
|
// claim resolves to neither of them, and the collision is invisible from |
| 615 |
|
// outside the space. |
| 616 |
1 |
var claimed []string |
| 617 |
4 |
for _, p := range arc.All() { |
| 618 |
4 |
if p.DocID == id { |
| 619 |
2 |
claimed = append(claimed, p.Path) |
| 620 |
2 |
} |
| 621 |
|
} |
| 622 |
1 |
if len(claimed) > 1 { |
| 623 |
1 |
return nil, false, fmt.Errorf("id: %q is claimed by %d documents (%s) and resolves to none of them; address one of them by path instead", |
| 624 |
1 |
id, len(claimed), strings.Join(claimed, ", ")) |
| 625 |
1 |
} |
| 626 |
0 |
return nil, false, nil |
| 627 |
|
} |
| 628 |
23 |
func spaceModel(sp *service.Space) *model.Space { |
| 629 |
23 |
return &model.Space{ |
| 630 |
23 |
Owner: sp.Ref.Owner, |
| 631 |
23 |
Name: sp.Ref.Name, |
| 632 |
23 |
Ref: sp.Ref.String(), |
| 633 |
23 |
Created: sp.Created, |
| 634 |
23 |
} |
| 635 |
23 |
} |
| 636 |
13 |
func spaceModels(spaces []*service.Space) []*model.Space { |
| 637 |
13 |
out := make([]*model.Space, 0, len(spaces)) |
| 638 |
21 |
for _, sp := range spaces { |
| 639 |
21 |
out = append(out, spaceModel(sp)) |
| 640 |
21 |
} |
| 641 |
13 |
return out |
| 642 |
|
} |
| 643 |
6 |
func projectModel(ref core.ProjectRef) *model.Project { |
| 644 |
6 |
return &model.Project{ |
| 645 |
6 |
Owner: ref.Owner, |
| 646 |
6 |
Name: ref.Name, |
| 647 |
6 |
Ref: ref.String(), |
| 648 |
6 |
Meta: ref.IsMeta(), |
| 649 |
6 |
} |
| 650 |
6 |
} |
| 651 |
9 |
func documentModel(space core.SpaceRef, rev string, pinned bool, page *doc.Page, body []byte) *model.Document { |
| 652 |
9 |
front, _ := doc.ParseFront(body) |
| 653 |
9 |
return &model.Document{ |
| 654 |
9 |
Space: space.String(), |
| 655 |
9 |
ID: page.ID, |
| 656 |
9 |
DocID: nilIfEmpty(page.DocID), |
| 657 |
9 |
Path: page.Path, |
| 658 |
9 |
Rev: rev, |
| 659 |
9 |
Blob: page.Blob, |
| 660 |
9 |
Pinned: pinned, |
| 661 |
9 |
Title: page.Title, |
| 662 |
9 |
Status: string(page.Status), |
| 663 |
9 |
Section: page.Section, |
| 664 |
9 |
Summary: page.Summary, |
| 665 |
9 |
Type: front.Type, |
| 666 |
9 |
Supersedes: front.Supersedes, |
| 667 |
9 |
Tags: nonNil(page.Tags), |
| 668 |
9 |
Owners: nonNil(front.Owners), |
| 669 |
9 |
Markdown: string(body), |
| 670 |
9 |
} |
| 671 |
9 |
} |
| 672 |
2 |
func proposalModel(p Proposal) (*model.Proposal, error) { |
| 673 |
2 |
state, err := proposalState(p.State) |
| 674 |
2 |
if err != nil { |
| 675 |
0 |
return nil, err |
| 676 |
0 |
} |
| 677 |
2 |
m := &model.Proposal{ |
| 678 |
2 |
ID: p.ID, |
| 679 |
2 |
Space: p.Space.String(), |
| 680 |
2 |
Title: p.Title, |
| 681 |
2 |
Rationale: p.Rationale, |
| 682 |
2 |
BaseRev: p.BaseRev, |
| 683 |
2 |
Branch: p.Branch, |
| 684 |
2 |
State: state, |
| 685 |
2 |
MergedRev: nilIfEmpty(p.MergedRev), |
| 686 |
2 |
Agent: p.Agent, |
| 687 |
2 |
AgentSession: p.AgentSession, |
| 688 |
2 |
Created: p.Created, |
| 689 |
2 |
Resolved: p.Resolved, |
| 690 |
2 |
} |
| 691 |
2 |
// Approval is empty until a proposal merges, and the difference between |
| 692 |
2 |
// human and policy approval is one a reader must be able to see: reporting |
| 693 |
2 |
// either value for an unmerged proposal would launder unreviewed agent |
| 694 |
2 |
// output as blessed, or claim a review that did not happen. |
| 695 |
2 |
if p.Approval != "" { |
| 696 |
1 |
approval, err := proposalApproval(p.Approval) |
| 697 |
1 |
if err != nil { |
| 698 |
0 |
return nil, err |
| 699 |
0 |
} |
| 700 |
1 |
m.Approval = &approval |
| 701 |
|
} |
| 702 |
2 |
return m, nil |
| 703 |
|
} |
| 704 |
2 |
func proposalState(s core.ProposalState) (model.ProposalState, error) { |
| 705 |
2 |
out := model.ProposalState(strings.ToUpper(string(s))) |
| 706 |
2 |
if !out.IsValid() { |
| 707 |
0 |
return "", fmt.Errorf("proposal state %q is not one of %v", s, model.AllProposalState) |
| 708 |
0 |
} |
| 709 |
2 |
return out, nil |
| 710 |
|
} |
| 711 |
1 |
func proposalApproval(a core.Approval) (model.Approval, error) { |
| 712 |
1 |
out := model.Approval(strings.ToUpper(string(a))) |
| 713 |
1 |
if !out.IsValid() { |
| 714 |
0 |
return "", fmt.Errorf("approval %q is not one of %v", a, model.AllApproval) |
| 715 |
0 |
} |
| 716 |
1 |
return out, nil |
| 717 |
|
} |
| 718 |
4 |
func spaceRef(owner, name string) (core.SpaceRef, error) { |
| 719 |
4 |
ref := core.SpaceRef{Owner: strings.TrimSpace(owner), Name: strings.TrimSpace(name)} |
| 720 |
4 |
if err := core.ValidateOwner(ref.Owner); err != nil { |
| 721 |
1 |
return core.SpaceRef{}, fmt.Errorf("owner: %w", err) |
| 722 |
1 |
} |
| 723 |
3 |
if err := core.ValidateSpaceName(ref.Name); err != nil { |
| 724 |
0 |
return core.SpaceRef{}, fmt.Errorf("name: %w", err) |
| 725 |
0 |
} |
| 726 |
3 |
return ref, nil |
| 727 |
|
} |
| 728 |
5 |
func projectRef(owner, name string) (core.ProjectRef, error) { |
| 729 |
5 |
ref := core.ProjectRef{Owner: strings.TrimSpace(owner), Name: strings.TrimSpace(name)} |
| 730 |
5 |
if err := core.ValidateOwner(ref.Owner); err != nil { |
| 731 |
0 |
return core.ProjectRef{}, fmt.Errorf("owner: %w", err) |
| 732 |
0 |
} |
| 733 |
5 |
if ref.IsMeta() { |
| 734 |
1 |
return ref, nil |
| 735 |
1 |
} |
| 736 |
4 |
if err := core.ValidateProjectName(ref.Name); err != nil { |
| 737 |
0 |
return core.ProjectRef{}, fmt.Errorf("name: %w", err) |
| 738 |
0 |
} |
| 739 |
4 |
return ref, nil |
| 740 |
|
} |
| 741 |
|
|
| 742 |
|
// webhookAuthorized permits only the instance owner to touch webhooks. It is the |
| 743 |
|
// whole ACL of the webhook chapter of this schema: createUserWebhook, |
| 744 |
|
// deleteUserWebhook, userWebhooks and userWebhook all call it, and the two field |
| 745 |
|
// resolvers that carry webhook data (UserWebhookSubscription.deliveries, |
| 746 |
|
// WebhookDelivery.subscription) are reachable only through an object one of |
| 747 |
|
// those four returned. |
| 748 |
|
// |
| 749 |
|
// The owner and nobody else, and specifically not an agent. That is the |
| 750 |
|
// pre-conversion behaviour preserved deliberately rather than caution: before |
| 751 |
|
// /query moved to the anonymous router, core-go's auth.Middleware plus the |
| 752 |
|
// daemon's ownerOnly wrapper refused every caller whose username was not |
| 753 |
|
// [sr.ht] owner-name, so an agent could not reach any of these fields at all. |
| 754 |
|
// The move opened the endpoint to tokens.sr.ht working tokens, and checking only |
| 755 |
|
// AUTH_INTERNAL would have let a token carrying nothing but authn.ActionRead |
| 756 |
|
// create and delete subscriptions, and read every subscription's URL, stored |
| 757 |
|
// query and delivery bodies — a read grant buying a mutation, which is the one |
| 758 |
|
// thing a grant vocabulary exists to prevent. |
| 759 |
|
// |
| 760 |
|
// The identity is therefore asked first, and asked of spec's own principal, |
| 761 |
|
// because that is the only value left that still tells the owner from an agent: |
| 762 |
|
// coreauth maps both to AUTH_INTERNAL on purpose, since INTERNAL is what |
| 763 |
|
// core-go's NewAuthConfig and FilterWebhooks demand of anyone at all. |
| 764 |
|
// |
| 765 |
|
// Opening this to agents is a grant-vocabulary decision and not a code one. It |
| 766 |
|
// needs a third grant beside authn.ActionRead and authn.ActionPropose, spelled |
| 767 |
|
// once and minted by tokens.sr.ht; inventing that string here would produce a |
| 768 |
|
// grant no existing token carries and no daemon has ever issued. |
| 769 |
|
// |
| 770 |
|
// The consequence today is that webhook management is unreachable over /query, |
| 771 |
|
// because this endpoint accepts no credential that resolves to the owner: authn |
| 772 |
|
// produces authn.KindOwner from the unified-login cookie alone, and this |
| 773 |
|
// endpoint reads no cookie. The check is written against the right predicate |
| 774 |
|
// regardless, so a credential plane that does yield the owner works the moment |
| 775 |
|
// it exists instead of needing this rule rediscovered. |
| 776 |
16 |
func webhookAuthorized(ctx context.Context) error { |
| 777 |
16 |
if !authn.PrincipalFromContext(ctx).IsOwner() { |
| 778 |
15 |
return accessDenied() |
| 779 |
15 |
} |
| 780 |
|
// The shape core-go's webhook engine demands of whoever got this far. |
| 781 |
|
// NewAuthConfig refuses AUTH_COOKIE outright and panics on a method it does |
| 782 |
|
// not recognise, and FilterWebhooks keys its subscription filter off this |
| 783 |
|
// field; coreauth is what makes the owner INTERNAL. Asserting it here is |
| 784 |
|
// what stops a change in that mapping from reaching those two as a panic. |
| 785 |
1 |
if auth.ForContext(ctx).AuthMethod != auth.AUTH_INTERNAL { |
| 786 |
0 |
return accessDenied() |
| 787 |
0 |
} |
| 788 |
1 |
return nil |
| 789 |
|
} |
| 790 |
|
|
| 791 |
|
// accessDenied builds a fresh ERR_ACCESS_DENIED, and the freshness is the whole |
| 792 |
|
// point of the function. |
| 793 |
|
// |
| 794 |
|
// coreerrors.ErrAccessDenied is a package-level *gqlerror.Error — one object, |
| 795 |
|
// shared by every caller in the process — and gqlgen writes the field path onto |
| 796 |
|
// the error it is given (graphql/error.go: `gqlErr.Path = GetPath(ctx)`). Return |
| 797 |
|
// the singleton and that assignment lands on the shared object, so the next |
| 798 |
|
// refusal anywhere in the daemon carries the previous one's path: measured |
| 799 |
|
// against a running daemon, a refused `deleteUserWebhook`, `userWebhooks` and |
| 800 |
|
// `userWebhook` all reported `"path":["createUserWebhook"]`, the field some |
| 801 |
|
// earlier request had asked for. It is a data race on a global as well as a |
| 802 |
|
// wrong answer. |
| 803 |
|
// |
| 804 |
|
// The bug is core-go's and predates this endpoint, but it was latent while the |
| 805 |
|
// daemon's ownerOnly middleware refused a non-owner before any resolver ran. |
| 806 |
|
// Refusing agents here makes it the common path, so it is fixed rather than |
| 807 |
|
// inherited. Same message and same code as the singleton; only the identity of |
| 808 |
|
// the object differs. |
| 809 |
15 |
func accessDenied() error { |
| 810 |
15 |
return coreerrors.New(coreerrors.AccessDenied, "Access denied") |
| 811 |
15 |
} |
| 812 |
8 |
func orNull(err error) error { |
| 813 |
8 |
if errors.Is(err, service.ErrNotFound) { |
| 814 |
3 |
return nil |
| 815 |
3 |
} |
| 816 |
5 |
return err |
| 817 |
|
} |
| 818 |
31 |
func deref(s *string) string { |
| 819 |
31 |
if s == nil { |
| 820 |
16 |
return "" |
| 821 |
16 |
} |
| 822 |
15 |
return *s |
| 823 |
|
} |
| 824 |
11 |
func nilIfEmpty(s string) *string { |
| 825 |
11 |
if s == "" { |
| 826 |
3 |
return nil |
| 827 |
3 |
} |
| 828 |
8 |
return &s |
| 829 |
|
} |
| 830 |
18 |
func nonNil(s []string) []string { |
| 831 |
18 |
if s == nil { |
| 832 |
10 |
return []string{} |
| 833 |
10 |
} |
| 834 |
8 |
return s |
| 835 |
|
} |