Administrator
  • Joined on 2025-04-12

Ava3v.WebApi.Core (0.7.0)

Published 2026-09-15 23:20:27 +03:00 by Administrator

Installation

dotnet nuget add source --name Administrator --username your_username --password your_token https://git.ava3v.ru/api/packages/Administrator/nuget/index.json
dotnet add package --source Administrator --version 0.7.0 Ava3v.WebApi.Core

About this package

Общее веб-ядро ava3v: minimal-API → MediatR (CQRS) с билдерами (GET/POST/PUT/DELETE/file/form-data), базовые команды/запросы/хендлеры, ValidationBehavior (FluentValidation), единый маппинг ошибок в HTTP, локализация, контексты (correlation/current-user), JWT. Извлечено из WareHub.

Ava3vLibs

Монорепозиторий общих .NET-библиотек ava3v.

  • Общая версия пакетов — <Version> в Directory.Build.props (одна на всю монорепу).
  • Версии внешних зависимостей — в Directory.Packages.props (Central Package Management), меняются в одном месте.
  • Приватный NuGet-фид: https://git.ava3v.com/api/packages/Administrator/nuget.

Пакеты (зависимость строго вниз)

Пакет Зависит от Назначение
Ava3v.Abstractions — Базовые контракты (ноль зависимостей): интерфейсы сущностей (IHasId/IHasParentId/IHasChildrenInfo и др.), EntityBase, DTO, Enumeration/UserKindEnum, контексты, Security, PagedResponse, паджинг-запросы (IPagedQuery/IHasSearch/ITreePagedQuery), ошибки бизнес-логики.
Ava3v.Components.Contract Abstractions (+EFCore) Грид-лоадер (фильтры/сортировка/поиск/уникальные значения по IQueryable) + построитель древовидного реестра.
Ava3v.WebApi.Core Abstractions (+MediatR/AspNetCore/…) minimal-API → MediatR (CQRS): билдеры (GET/POST/PUT/DELETE/file/form-data), middleware, локализация, хендлер-базы, JWT.
Ava3v.DataAccess Abstractions (+EFCore.Relational) Базовый DbContext (аудит/soft-delete/период действия), snake_case-конфиг сущностей, query-фильтры.

Публикация

Токен Gitea (personal access token с правом write:package) держи у себя — в репо не коммитим. Перед публикацией новой версии подними <Version> в Directory.Build.props (одну и ту же версию Gitea второй раз не примет; --skip-duplicate это проглотит).

Всё сразу (обычный путь)

cd ~/Ava3vLibs
rm -rf artifacts
dotnet pack Ava3vLibs.slnx -c Release -o artifacts

dotnet nuget push "artifacts/*.nupkg" \
  --source https://git.ava3v.com/api/packages/Administrator/nuget \
  --api-key <твой-gitea-токен> \
  --skip-duplicate

По отдельности

Порядок важен, только если фид пустой: Ava3v.Abstractions публикуй первым — остальные от него зависят. dotnet pack любого проекта складывает .nupkg в общий artifacts/.

Общий шаблон:

cd ~/Ava3vLibs
PROJ=Ava3v.Abstractions   # или Ava3v.Components.Contract / Ava3v.WebApi.Core / Ava3v.DataAccess

dotnet pack src/$PROJ/$PROJ.csproj -c Release -o artifacts
dotnet nuget push "artifacts/$PROJ.*.nupkg" \
  --source https://git.ava3v.com/api/packages/Administrator/nuget \
  --api-key <твой-gitea-токен> \
  --skip-duplicate

Конкретно по каждому:

1. Ava3v.Abstractions (сначала)

dotnet pack src/Ava3v.Abstractions/Ava3v.Abstractions.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.Abstractions.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate

2. Ava3v.Components.Contract

dotnet pack src/Ava3v.Components.Contract/Ava3v.Components.Contract.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.Components.Contract.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate

3. Ava3v.WebApi.Core

dotnet pack src/Ava3v.WebApi.Core/Ava3v.WebApi.Core.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.WebApi.Core.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate

4. Ava3v.DataAccess

dotnet pack src/Ava3v.DataAccess/Ava3v.DataAccess.csproj -c Release -o artifacts
dotnet nuget push "artifacts/Ava3v.DataAccess.*.nupkg" --source https://git.ava3v.com/api/packages/Administrator/nuget --api-key <токен> --skip-duplicate

Закоммитить исходники

git add -A
git commit -m "Ava3vLibs <version> — <что изменилось>"
git push

Как это потребляют другие проекты

В nuget.config потребителя добавить источник (один раз):

<packageSources>
  <add key="ava3v" value="https://git.ava3v.com/api/packages/Administrator/nuget/index.json" />
</packageSources>

Затем нужные пакеты (транзитивно тянут Ava3v.Abstractions):

<PackageReference Include="Ava3v.WebApi.Core" Version="0.2.0" />
<PackageReference Include="Ava3v.Components.Contract" Version="0.2.0" />
<PackageReference Include="Ava3v.DataAccess" Version="0.2.0" />

Массовые операции по выделению грида (Ava3v.Components.Contract 0.2.33+)

Грид с 0.1.158 умеет «выделить всё по фильтру»: на сервер приходит не список ключей, а GridSelectionRequest — флаг AllMatchingSelected, ExcludedKeys и Filter (тело getBackendRequest() грида) либо SelectedKeys для явного выделения. Разрешение в запрос и предохранитель — GridSelectionResolver:

// Команда: { GridSelectionRequest Selection; ... patch ... }
var rows = await db.Items.ResolveAsync(command.Selection, x => x.Id, maxAffectedRows: 10_000, ct);
// > лимита — BusinessLogicException с Code = "BulkLimitExceeded" ДО изменения данных (COUNT до лимита + 1)
await rows.ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, command.Status), ct);

ApplySelection — только запрос без проверки, EnsureWithinLimitAsync — только проверка. Лимит держите согласованным с фронтовым selectAllMatchingLimit (по умолчанию 10 000).

Заметки к выпуску

Полный список — в CHANGELOG.md.

0.5.0 — refresh-токены и сессии (аддитивно)

  • Ava3v.Abstractions.Sessions: контракты IAva3vRefreshTokenStore, IAva3vClaimsProvider, IAva3vUserStateProvider, IAva3vSessionEvents, коды отказа Ava3vSessionErrorCodes (invalid_refresh, refresh_reused, session_mismatch, user_blocked, user_inactive, refresh_expired, session_revoked), Ava3vSessionException (401 + код).
  • Ava3v.DataAccess.Sessions: сущность Ava3vRefreshToken (в базе только SHA-256 отпечаток), ApplyAva3vRefreshTokens(schema), AddAva3vRefreshTokenStore<TContext>(); ротация атомарна (UPDATE … WHERE revoked_at IS NULL + вставка преемника в одной транзакции).
  • Ava3v.WebApi.Core.Web.Sessions: IAva3vSessionService (StartSession / Refresh / Logout / RevokeSession / RevokeAllSessions / ListSessions), IAva3vRevokedSessionList на IDistributedCache (с Redis — общий между экземплярами), IAva3vAuthCookieService (httpOnly, отдельный путь refresh), проверка отозванных sid и (по настройке) состояния пользователя в OnTokenValidated, AddAva3vSessions(configuration). Секции: Jwt:Sessions (сроки по виду пользователя, окно гонки, реакция на повтор), AuthCookie.
  • Протокол: refresh одноразовый; владелец сессии — запись токена; sid сохраняется при ротации; гонка вкладок (живой преемник в окне 60 с) — пара под тем же sid; повтор вне окна — отзыв всех сессий пользователя и гашение их access-токенов (настраивается), событие для аудита; абсолютный предел переносится при ротации.
  • Тесты: Sqlite (протокол, куки, состояние с кешем) и Postgres (гонка четырёх одновременных запросов с одним токеном, повтор между двумя экземплярами с общим списком отозванных).

0.4.0 — по замечаниям Cargo (ЛОМАЮЩЕЕ для колонок)

  • snake_case для колонок, ключей, внешних ключей и индексов — конвенция Ava3vSnakeCaseConvention включена в Ava3vDbContextBase по умолчанию (таблицы и раньше — ToTableInSnakeCase). Явные HasColumnName/HasName/ToTable не перебиваются. Потребителям на базовом контексте нужна новая миграция (колонки переименовываются); прежнее поведение — protected override bool UseSnakeCaseNaming => false.
  • AddAva3vJwtAuthentication идемпотентен: повторный вызов рядом с AddAva3vCoreWebApi (который уже включает JWT) больше не роняет старт «Scheme already exists: Bearer».
  • Ava3vJwtOptions регистрируется в DI (сам класс и IOptions<Ava3vJwtOptions>) — один источник истины для ключа/issuer/audience; новая настройка Jwt:AccessTokenLifetimeMinutes (15).
  • IAva3vTokenIssuer (режимы Symmetric/Both): IssueAccessToken(claims, lifetime?) и GenerateRefreshToken() теми же параметрами, что и проверка. Клеймы — за продуктом.
  • Двухдженериковые MapPostMediatrRequest/MapPutMediatrRequest (запрос из тела) с плейсхолдером в маршруте падают на старте понятной ошибкой вместо тихого default в параметре — берите перегрузку <TRequestParams, TRequest, TResponse>.

0.3.0 — ЛОМАЮЩЕЕ: авторство при добавлении

  • Ava3vDbContextBase: CreatedBy при добавлении ВСЕГДА берётся из текущего пользователя, заданное значение (в том числе прилетевшее из DTO через маппер) игнорируется. Раньше заполненный CreatedBy не трогался — клиент мог записать запись от чужого имени.
  • Сохранить исходного автора можно только из кода, явным режимом с областью действия: using (context.PreserveAuthorship()) { … await context.SaveChangesAsync(); } — для сидеров, генераторов демо-данных, переноса и синхронизации данных. В режиме CreatedBy = 0 всё равно заменяется текущим пользователем; UpdatedBy/DeletedBy режим не затрагивает; автор записи журнала аудита — всегда тот, кто выполняет операцию. CreatedAt это решение не трогает.
  • Что проверить у себя: все места, где CreatedBy задаётся явно (сидеры, импорты, DevData, генераторы, вызываемые через API — там автором станет вызвавший), обернуть в режим.
  • Аудит: тесты на настоящем Postgres (tests/Ava3v.Audit.PostgresTests, Testcontainers postgres:14.1 либо TEST_POSTGRES_CONNECTION); без Docker локально — Skipped, в CI (CI=true) — падение.

Сессии и refresh-токены (Ava3v.WebApi.Core + Ava3v.DataAccess, 0.5.0+)

// DI
services.AddAva3vCoreWebApi(configuration);                 // JWT + IAva3vTokenIssuer
services.AddAva3vRefreshTokenStore<AppDbContext>();         // таблица: modelBuilder.ApplyAva3vRefreshTokens("auth")
services.AddAva3vSessions(configuration);                   // сервис, куки, транспорт, список отозванных sid
services.AddScoped<IAva3vClaimsProvider, AppClaimsProvider>();       // состав клеймов — продукт (sid, id, user_kind добавит либа)
services.AddScoped<IAva3vUserStateProvider, AppUserStateProvider>(); // существует/активен/не заблокирован — по (вид, id)
services.AddScoped<IAva3vSessionEvents, SecurityJournal>();          // по желанию: reuse_detected, session_revoked
services.AddStackExchangeRedisCache(...);                            // на нескольких экземплярах; иначе память процесса
app.UseAuthentication(); app.UseAva3vSessions(); app.UseAuthorization();   // CSRF + предупреждения старта

// Логин (проверка пароля и попыток — в продукте); пользователь = (вид, id)
var user = new Ava3vUserKey("Internal", account.Id);
var tokens = await sessions.StartSessionAsync(user, Ava3vSessionClient.FromRequest(Request));
await responder.WriteAsync(Response, tokens, responder.DetectTransport(Request)); // Cookie или Body
// Обновление: refresh из тела (программный клиент) или из куки (браузер), access — заголовок или кука
var refresh = Ava3vSessionTokenReader.ReadRefresh(Request, cookies, body?.RefreshToken);
var pair = await sessions.RefreshAsync(refresh!, Ava3vSessionTokenReader.ReadAccess(Request, cookies), client);
await responder.WriteAsync(Response, pair, responder.DetectTransport(Request));
// Выход
await sessions.LogoutAsync(refresh, client); cookies.Clear(Response);
// Блокировка / удаление / смена пароля — только этот вид пользователя
await sessions.RevokeAllSessionsAsync(user, "password_changed");

Браузер и программные клиенты

  • Браузер: httpOnly-куки access (путь /) и refresh (путь AuthCookie:RefreshPath), рядом не-httpOnly ava3v_csrf. На POST/PUT/PATCH/DELETE фронт кладёт значение ava3v_csrf в заголовок X-CSRF-Token, иначе 403 csrf_failed. Обновление — тоже POST с кукой и CSRF-заголовком.
  • Программный клиент: логин → Transport=Body (заголовок X-Token-Transport: body либо запрос уже с Authorization: Bearer) → токены в JSON; дальше Authorization: Bearer, refresh — значение в теле запроса. CSRF не проверяется, куки не ставятся.
  • Кука и заголовок в одном запросе — считается заголовок.
  • SignalR: Jwt:QueryTokenPaths: ["/hub"] — ?access_token= принимается только на этих путях.
  • Коды 401: auth_required (нет токена), token_expired (обновись; плюс заголовок Jwt:ExpiredHeaderName), session_revoked, user_blocked, user_inactive. Refresh: invalid_refresh, refresh_reused, session_mismatch, refresh_expired.
  • Режим Both: свой токен в заголовке проходит по iss, внешний — на внешнюю схему. Настройки:
"Jwt": { "AccessTokenLifetimeMinutes": 15,
  "ExpiredHeaderName": "IS-TOKEN-EXPIRED", "QueryTokenPaths": ["/hub"],
  "Sessions": { "DefaultPolicy": { "IdleMinutes": 720, "AbsoluteMinutes": 43200, "RaceWindowSeconds": 60, "ReuseReaction": "RevokeAllSessions" },
                "Policies": { "External": { "IdleMinutes": 120, "AbsoluteMinutes": 480 },
                              "Integration": { "ReuseReaction": "RevokeSession" } },
                "CheckUserStateOnEachRequest": false, "UserStateCacheSeconds": 60 } },
"AuthCookie": { "AccessTokenName": "ava3v_access_token", "RefreshTokenName": "ava3v_refresh_token",
                "RefreshPath": "/api/auth", "SameSite": "Strict", "Secure": true,
                "Csrf": { "Enabled": true, "CookieName": "ava3v_csrf", "HeaderName": "X-CSRF-Token" } }

Переход с хранения токена как есть на отпечатки — однократный разлогин всех пользователей (старые записи не подходят под новую таблицу), переходного режима нет.

API-ключи для интеграций (0.7.0+)

services.AddAva3vApiKeyStore<AppDbContext>();      // таблица: modelBuilder.ApplyAva3vApiKeys("auth")
services.AddAva3vApiKeys(configuration);          // ПОСЛЕ AddAva3vCoreWebApi: схема X-Api-Key + селектор
// Выдача (ключ показывается один раз), отзыв, список — из админки продукта
var issued = await apiKeys.CreateAsync(new Ava3vUserKey("Service", account.Id), "1c-sync", TimeSpan.FromDays(365));
return issued.Key; // ak_{prefix}_{secret}

Запрос интеграции: X-Api-Key: ak_… — без сессий, refresh и CSRF. Владелец — сервисная учётка (вид, id); блокировка владельца гасит ключ. Лимит живых ключей ApiKeys:MaxActivePerOwner (3): ротация — выдать новый, перевести клиента, отозвать старый.

Переезд без разлогина (по желанию, 0.7.0+)

new Ava3vRefreshTokenImport(store).ImportAsync(rows) один раз при выкатке: прежние refresh становятся записями с отпечатком и новым sid. Старые access-токены без sid/user_kind 0.6.0 не принимает — клиент сделает refresh и получит нормальную пару. Без импорта — однократный разлогин.

Аудит (Ava3v.Audit, 0.3.0+)

Журнал изменений по полям и снимки версий в транзакции самих изменений. Контракты — в Ava3v.Abstractions.Audit, реализация — в пакете Ava3v.Audit. Подключение:

// DI
services.AddAva3vAudit<AppDbContext>(o => { o.Schema = "audit"; o.WriteSnapshots = true; });
services.AddDbContext<AppDbContext>((sp, o) => o.UseNpgsql(cs).UseAva3vAudit(sp));
// Своё: описания полей, резолверы ссылок, форматтеры, скоуп тенанта, политика доступа, имена авторов
services.AddScoped<IAuditFieldDescriptorProvider, IssueAuditFields>();
services.AddScoped<IAuditReferenceResolver, UserReferenceResolver>();   // читать через IgnoreQueryFilters!
services.AddScoped<IAuditScopeProvider, WorkspaceAuditScope>();         // TenantId/ScopeId + фильтр чтения
services.AddScoped<IAuditAccessPolicy, EntityAuditAccessPolicy>();      // без неё — любому аутентифицированному
services.AddScoped<IAuditUserNameResolver, UserNameResolver>();

// Контекст: наследник Ava3vDbContextBase (штамп метаданных и мягкое удаление — до аудита)
protected override void OnModelCreating(ModelBuilder b) { base.OnModelCreating(b); b.ApplyAva3vAudit(auditOptions); }
public bool SuppressAudit { get; set; }   // IAuditSuppressible — миграции и массовая загрузка

// Эндпоинт истории: GET api/audit/getShort?entityType=Issue&entityId=5&skip=0&take=20
app.MapAva3vAudit();
// Программно: IAuditLogService.GetShortAsync(...), IEntitySnapshotService.GetAsOfAsync<T>(id, moment)

Правила:

  • журнал пишется вторым SaveChanges того же контекста в той же транзакции; без явной транзакции интерсептор открывает свою и коммитит после журнала; откат/ошибка/отмена журнал не оставляют;
  • EnableRetryOnFailure несовместим с собственной транзакцией: оборачивайте сохранения в CreateExecutionStrategy().ExecuteAsync(...) (интерсептор бросает понятную ошибку, если этого не сделать), либо UseOwnTransaction = false;
  • ExecuteUpdate/ExecuteDelete обходят и метаданные, и мягкое удаление, и журнал — в проекте нужен архитектурный тест с разрешённым списком таких вызовов;
  • мягкое удаление пишется как Deleted; lifecycle-поля, [AuditIgnore], INotAuditable не пишутся;
  • автор записи — текущий пользователь (не CreatedBy сущности); ключ сущности — long;
  • таблицы {schema}.audit_log и {schema}.entity_snapshot — миграция на стороне потребителя.

Dependencies

ID Version Target Framework
Ava3v.Abstractions 0.7.0 net10.0
FluentValidation 12.1.1 net10.0
FluentValidation.DependencyInjectionExtensions 12.1.1 net10.0
Mapster 10.0.8 net10.0
MediatR 12.4.1 net10.0
Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 net10.0
Microsoft.AspNetCore.OpenApi 10.0.9 net10.0
Microsoft.EntityFrameworkCore 10.0.9 net10.0
Microsoft.OpenApi 2.9.0 net10.0
Details
NuGet
2026-09-15 23:20:27 +03:00
11
ava3v
90 KiB
Assets (2)
Versions (67) View all
0.11.0 2026-10-22
0.10.3 2026-10-05
0.10.2 2026-10-05
0.10.1 2026-09-26
0.10.0 2026-09-21