{"record":{"id":"d10212649238f003","repo":"Kareadita/Kavita","slug":"external-source-required","errorCode":null,"errorMessage":"external-source-required","messagePattern":"external-source-required","errorType":"exception","errorClass":"KavitaException","httpStatus":400,"severity":"warning","filePath":"Kavita.Services/StreamService.cs","lineNumber":297,"sourceCode":"        await unitOfWork.CommitAsync(ct);\n        if (!stream.Visible) return;\n        await eventHub.SendMessageToAsync(MessageFactory.SideNavUpdate, MessageFactory.SideNavUpdateEvent(userId),\n            userId, ct);\n    }\n\n    public async Task<ExternalSourceDto> CreateExternalSource(int userId, ExternalSourceDto dto,\n        CancellationToken ct = default)\n    {\n        var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId,\n            AppUserIncludes.ExternalSources, ct);\n        if (user == null) throw new KavitaException(\"not-authenticated\");\n\n        if (user.ExternalSources.Any(s => s.Host == dto.Host))\n        {\n            throw new KavitaException(\"external-source-already-exists\");\n        }\n\n        if (string.IsNullOrEmpty(dto.Name)) throw new KavitaException(\"external-source-required\");\n        if (!UrlHelper.StartsWithHttpOrHttps(dto.Host)) throw new KavitaException(\"external-source-host-format\");\n\n\n        var newSource = new AppUserExternalSource()\n        {\n            Name = dto.Name,\n            Host = UrlHelper.EnsureEndsWithSlash(UrlHelper.EnsureStartsWithHttpOrHttps(dto.Host)),\n            ApiKey = dto.ApiKey\n        };\n        user.ExternalSources.Add(newSource);\n\n        unitOfWork.UserRepository.Update(user);\n        await unitOfWork.CommitAsync(ct);\n\n        dto.Id = newSource.Id;\n\n        return dto;\n    }","sourceCodeStart":279,"sourceCodeEnd":315,"githubUrl":"https://github.com/Kareadita/Kavita/blob/9c3e5400007f8a0282f7d883f2ad5e71716e514d/Kavita.Services/StreamService.cs#L279-L315","documentation":"Thrown by StreamService.CreateExternalSource when dto.Name is null or empty. Note the mismatch: the localization key 'external-source-required' renders as 'Host is required', but the guard actually checks the Name field, not the Host (the Host-format check is a separate throw). It is a localized KavitaException surfaced as HTTP 500.","triggerScenarios":"POST /api/streams/create-external-source with a dto whose Name is null, empty, or whitespace-only (the string.IsNullOrEmpty(dto.Name) branch).","commonSituations":"User fills in the Host/ApiKey but leaves the Name field blank; the form posts an incomplete object; a client bug drops the name property during serialization.","solutions":["Make the Name field required in the form and disable Save until it is non-empty.","Validate dto.name is a non-empty string before posting.","Fix the misleading message server-side if the requirement truly is Name (or add a Host emptiness check to match the text)."],"exampleFix":"// before\nsave(dto: ExternalSourceDto) { return this.http.post('streams/create-external-source', dto); }\n// after\nsave(dto: ExternalSourceDto) {\n  if (!dto?.name?.trim()) return throwError(() => new Error('Name is required'));\n  return this.http.post('streams/create-external-source', dto);\n}","handlingStrategy":"validation","validationCode":"function hasExternalSourceName(dto: ExternalSourceDto): boolean {\n  return !!dto?.name?.trim();\n}","typeGuard":"function isExternalSourceDtoWith(o: unknown): o is ExternalSourceDto {\n  return typeof o === 'object' && o !== null && typeof (o as any).name === 'string' && (o as any).name.trim().length > 0;\n}","tryCatchPattern":null,"preventionTips":["Mark the Name field required in the form and disable Save until filled.","Remember the server message says 'Host' but the real check is on Name.","Trim whitespace before validating."],"tags":["external-source","validation","required-field"],"backgroundTag":null,"analyzedSha":"9c3e5400007f8a0282f7d883f2ad5e71716e514d","analyzedAt":"2026-08-13T19:06:05.897Z","schemaVersion":2},"datasetVersion":"2026-08-13T19:17:28.613Z"}