From 6c99d7ca52bf20aaeb011cf26108cdd656a5eda4 Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cbjpdev@users.noreply.github.com> Date: Sat, 31 Aug 2024 08:15:35 +0800 Subject: [PATCH 01/33] Initial commit --- .gitignore | 398 +++++++++++++++++++++++++++++++ LICENSE | 674 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 + 3 files changed, 1074 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a30d25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,398 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a12e93f --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# CleanApi +Dotnet API based on Clean Architecture From 56cd4731cedd804450b95c8f8100925fb4fe2133 Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Sat, 31 Aug 2024 09:16:59 +0800 Subject: [PATCH 02/33] add folder structure --- .DS_Store | Bin 0 -> 6148 bytes .config/dotnet-tools.json | 5 + .editorconfig | 364 +++++++++++++++++++++++ .vscode/launch.json | 7 + .vscode/settings.json | 3 + CleanApi.sln | 67 +++++ Directory.Build.props | 10 + Directory.Packages.props | 16 + global.json | 6 + src/.DS_Store | Bin 0 -> 6148 bytes src/Api/Api.csproj | 14 + src/Api/Api.http | 6 + src/Api/Program.cs | 44 +++ src/Api/Properties/launchSettings.json | 41 +++ src/Api/appsettings.Development.json | 8 + src/Api/appsettings.json | 9 + src/Application/Application.csproj | 9 + src/Application/Class1.cs | 6 + src/Domain/Class1.cs | 6 + src/Domain/Domain.csproj | 9 + src/Infrastructure/Class1.cs | 6 + src/Infrastructure/Infrastructure.csproj | 9 + tests/UnitTests/UnitTest1.cs | 10 + tests/UnitTests/UnitTests.csproj | 23 ++ 24 files changed, 678 insertions(+) create mode 100644 .DS_Store create mode 100644 .config/dotnet-tools.json create mode 100644 .editorconfig create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 CleanApi.sln create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 global.json create mode 100644 src/.DS_Store create mode 100644 src/Api/Api.csproj create mode 100644 src/Api/Api.http create mode 100644 src/Api/Program.cs create mode 100644 src/Api/Properties/launchSettings.json create mode 100644 src/Api/appsettings.Development.json create mode 100644 src/Api/appsettings.json create mode 100644 src/Application/Application.csproj create mode 100644 src/Application/Class1.cs create mode 100644 src/Domain/Class1.cs create mode 100644 src/Domain/Domain.csproj create mode 100644 src/Infrastructure/Class1.cs create mode 100644 src/Infrastructure/Infrastructure.csproj create mode 100644 tests/UnitTests/UnitTest1.cs create mode 100644 tests/UnitTests/UnitTests.csproj diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..bc3010721f20b539a5abb96da48af9d7e565fe94 GIT binary patch literal 6148 zcmeHKO^ee&7=EYi+SFC-ii#H@;7v+xMOnd1+f5G(u7`~u2bJ11WjB~+LettqS;$cl zWdDgbe}ZREdh(BW(Puu&CT-Wl9z>RTVdj~c_jxDpJR~z2B4VAX-z2IMk%c0#bP?4W z6Mj)vte{7(0GXU4qBg}8P@R$$wiz%A7zO?}1;n>|iFPQYA@wQy{e~3nhJ4_K+yh5! z5T8-%zonr?RHnzYi`XtbA(y&&HoLXNuFyW1ZE|Q2Sf>&BOA#NJB8u2W#xz)BZGrVO zlZ9q^vn1BJe(~ZkNG6TOcUCBtHZGK{vbAZwb`SZ?o%AQuq|+b26uqaMhu)&y_nrsC zS+9C+hewls5DmvNAn=Evym}EtKA&~?H1ZP}>nev;u`0dljrqJ$v$yXZ)$Dom{)6qB z{Scj_igo?=-ADVqx8W$_?~yl*VW*UKS>pmeprO|i9yHwnAH@sTiD$$eN3YU4{&n@! zUi)i1>-GQQjSo+o^_@G8LLMDpCXw9+P4G4(sFS?*g=Zk#$!MQpuE{y$&E~!JfOD~l6nt&Q>qMVR2F3S|f?eH_b% fIErtgNQ2K74PZ-Sst_YE^CKW-FqKi@pDOSNPt&_I literal 0 HcmV?d00001 diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..517f9b3 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "isRoot": true, + "tools": {} +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..4a9952c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,364 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# var preferences +csharp_style_var_elsewhere = false:silent +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = false:silent + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5c7247b --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,7 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ebda1eb --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dotnet.defaultSolution": "CleanApi.sln" +} \ No newline at end of file diff --git a/CleanApi.sln b/CleanApi.sln new file mode 100644 index 0000000..e7ee670 --- /dev/null +++ b/CleanApi.sln @@ -0,0 +1,67 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B89C7703-FA6E-4F45-81D3-6591C0E97548}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "src\Api\Api.csproj", "{21EE4D82-D963-4832-B621-B8B5138377B7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "src\Application\Application.csproj", "{A804F7DE-51BF-44CB-A2FA-680FC68571DB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\Domain.csproj", "{3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{662A350A-2580-4B96-A1E6-C8C742E60F78}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{09BA79D0-7A13-419E-A21E-C32B3E7EAE0C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\UnitTests\UnitTests.csproj", "{3BE68B1A-FF8A-49C5-A787-6B26600D12C8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F1E8E655-4E70-45DD-9EBA-7CFF831E84A9}" + ProjectSection(SolutionItems) = preProject + Directory.Build.props = Directory.Build.props + .editorconfig = .editorconfig + global.json = global.json + .gitignore = .gitignore + Directory.Packages.props = Directory.Packages.props + .config\dotnet-tools.json = .config\dotnet-tools.json + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {21EE4D82-D963-4832-B621-B8B5138377B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21EE4D82-D963-4832-B621-B8B5138377B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21EE4D82-D963-4832-B621-B8B5138377B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21EE4D82-D963-4832-B621-B8B5138377B7}.Release|Any CPU.Build.0 = Release|Any CPU + {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Release|Any CPU.Build.0 = Release|Any CPU + {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Release|Any CPU.Build.0 = Release|Any CPU + {662A350A-2580-4B96-A1E6-C8C742E60F78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {662A350A-2580-4B96-A1E6-C8C742E60F78}.Debug|Any CPU.Build.0 = Debug|Any CPU + {662A350A-2580-4B96-A1E6-C8C742E60F78}.Release|Any CPU.ActiveCfg = Release|Any CPU + {662A350A-2580-4B96-A1E6-C8C742E60F78}.Release|Any CPU.Build.0 = Release|Any CPU + {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {21EE4D82-D963-4832-B621-B8B5138377B7} = {B89C7703-FA6E-4F45-81D3-6591C0E97548} + {A804F7DE-51BF-44CB-A2FA-680FC68571DB} = {B89C7703-FA6E-4F45-81D3-6591C0E97548} + {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA} = {B89C7703-FA6E-4F45-81D3-6591C0E97548} + {662A350A-2580-4B96-A1E6-C8C742E60F78} = {B89C7703-FA6E-4F45-81D3-6591C0E97548} + {3BE68B1A-FF8A-49C5-A787-6B26600D12C8} = {09BA79D0-7A13-419E-A21E-C32B3E7EAE0C} + EndGlobalSection +EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..2b78872 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + + net8.0 + true + + enable + enable + + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..3121cd2 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,16 @@ + + + + true + + + + + + + + + + + + \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..5c4e745 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.100", + "rollForward": "latestFeature" + } +} \ No newline at end of file diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..84e94681384b6e237748307f35fa40cc9bbe9497 GIT binary patch literal 6148 zcmeHK%}N6?5T4j-Q;N`oipPN0f~_bOysX>$0JK=RFGW|Ex`Ha`F${As@hPzL~sjZl!XLTGrUYr_Q-3NuFv=P-mg%ErSL z6aB>@`gRE&NMHaVeEa?=(GmJSqG1w`C(Y)k*eI1Zw<@wCYw|vL(pfMGC)2SXj&8}i z(mII>J&dm7LDsA9baXli<8&}G32}dbA$K=%+Si${r)hs|as%s-Rax!T_hz$ZL$#0Q z4K-^WpR^n56peXR?j9VTUG|=nmsG!5Dh0A`WZPi@@2D(Q^%{)QM5mAFEAonXMrME+ zUpUs%q^k^ zMc7nCn=0HBL)dg2S1!(Tm|L{zAk4}*k6XETya=;8jw=%m!n4RdGr$ZiGElN+ozDLY z{AE@?@)uLsVg{Ijf5w0)cZ05vMY*$esXRJs6}AU#BovpEf`a)ZDNA``=`W5LI Z=Q+$R(kwJ?(h>P0U + + + net8.0 + enable + enable + + + + + + + + diff --git a/src/Api/Api.http b/src/Api/Api.http new file mode 100644 index 0000000..7a2b9ed --- /dev/null +++ b/src/Api/Api.http @@ -0,0 +1,6 @@ +@Api_HostAddress = http://localhost:5219 + +GET {{Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/Api/Program.cs b/src/Api/Program.cs new file mode 100644 index 0000000..fda9a7d --- /dev/null +++ b/src/Api/Program.cs @@ -0,0 +1,44 @@ +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +var summaries = new[] +{ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" +}; + +app.MapGet("/weatherforecast", () => +{ + var forecast = Enumerable.Range(1, 5).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + summaries[Random.Shared.Next(summaries.Length)] + )) + .ToArray(); + return forecast; +}) +.WithName("GetWeatherForecast") +.WithOpenApi(); + +app.Run(); + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/src/Api/Properties/launchSettings.json b/src/Api/Properties/launchSettings.json new file mode 100644 index 0000000..fa2d512 --- /dev/null +++ b/src/Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:46994", + "sslPort": 44360 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5219", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7285;http://localhost:5219", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/src/Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Api/appsettings.json b/src/Api/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/src/Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj new file mode 100644 index 0000000..bb23fb7 --- /dev/null +++ b/src/Application/Application.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/src/Application/Class1.cs b/src/Application/Class1.cs new file mode 100644 index 0000000..f1d69f9 --- /dev/null +++ b/src/Application/Class1.cs @@ -0,0 +1,6 @@ +namespace Application; + +public class Class1 +{ + +} diff --git a/src/Domain/Class1.cs b/src/Domain/Class1.cs new file mode 100644 index 0000000..dfc5368 --- /dev/null +++ b/src/Domain/Class1.cs @@ -0,0 +1,6 @@ +namespace Domain; + +public class Class1 +{ + +} diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj new file mode 100644 index 0000000..bb23fb7 --- /dev/null +++ b/src/Domain/Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/src/Infrastructure/Class1.cs b/src/Infrastructure/Class1.cs new file mode 100644 index 0000000..301b928 --- /dev/null +++ b/src/Infrastructure/Class1.cs @@ -0,0 +1,6 @@ +namespace Infrastructure; + +public class Class1 +{ + +} diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj new file mode 100644 index 0000000..bb23fb7 --- /dev/null +++ b/src/Infrastructure/Infrastructure.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/tests/UnitTests/UnitTest1.cs b/tests/UnitTests/UnitTest1.cs new file mode 100644 index 0000000..791524d --- /dev/null +++ b/tests/UnitTests/UnitTest1.cs @@ -0,0 +1,10 @@ +namespace UnitTests; + +public class UnitTest1 +{ + [Fact] + public void Test1() + { + + } +} \ No newline at end of file diff --git a/tests/UnitTests/UnitTests.csproj b/tests/UnitTests/UnitTests.csproj new file mode 100644 index 0000000..a1d45f6 --- /dev/null +++ b/tests/UnitTests/UnitTests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + From 59b5e812123442c139df3424037ccd64b7da3f11 Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:39:14 +0800 Subject: [PATCH 03/33] implement basic structure for CleanApiStarter API with PostgreSQL and OpenTelemetry --- .editorconfig | 11 +- .gitignore | 4 + .vscode/launch.json | 7 - .vscode/settings.json | 3 - AGENTS.md | 111 ++++++++++++++ CleanApi.sln | 67 --------- CleanApiStarter.slnx | 28 ++++ Directory.Build.props | 2 +- Directory.Packages.props | 55 +++++-- README.md | 77 +++++++++- .../migrations/V001__create_words_table.sql | 11 ++ docker-compose.yml | 18 +++ global.json | 10 +- src/Api/Api.csproj | 14 -- src/Api/Api.http | 6 - src/Api/Program.cs | 44 ------ src/Api/Properties/launchSettings.json | 41 ------ src/Api/appsettings.Development.json | 8 - src/Application/Application.csproj | 9 -- src/Application/Class1.cs | 6 - src/CleanApiStarter.Api/Api.http | 18 +++ .../CleanApiStarter.Api.csproj | 19 +++ .../Endpoints/WordEndpoints.cs | 77 ++++++++++ src/CleanApiStarter.Api/GlobalUsings.cs | 13 ++ src/CleanApiStarter.Api/Program.cs | 25 ++++ .../Properties/launchSettings.json | 15 ++ .../appsettings.Development.json | 11 ++ .../appsettings.json | 0 .../CleanApiStarter.AppHost.csproj | 19 +++ src/CleanApiStarter.AppHost/Program.cs | 19 +++ .../Properties/launchSettings.json | 17 +++ .../CleanApiStarter.Application.csproj | 16 ++ .../DependencyInjection.cs | 10 ++ .../GlobalUsings.cs | 7 + .../Interfaces/IWordRepository.cs | 10 ++ .../Interfaces/IWordService.cs | 10 ++ .../Models/WordDto.cs | 18 +++ .../Services/WordService.cs | 105 +++++++++++++ .../CleanApiStarter.AspNetCore.csproj | 21 +++ src/CleanApiStarter.AspNetCore/Extensions.cs | 97 ++++++++++++ .../GlobalUsings.cs | 14 ++ .../CleanApiStarter.Domain.csproj | 6 + src/CleanApiStarter.Domain/Entities/Word.cs | 12 ++ .../CleanApiStarter.Infrastructure.csproj | 17 +++ .../Database/DatabaseConnectionFactory.cs | 14 ++ .../DependencyInjection.cs | 24 +++ .../GlobalUsings.cs | 14 ++ .../Repositories/WordRepository.cs | 121 +++++++++++++++ src/Domain/Class1.cs | 6 - src/Domain/Domain.csproj | 9 -- src/Infrastructure/Class1.cs | 6 - src/Infrastructure/Infrastructure.csproj | 9 -- .../CleanApiStarter.UnitTests.csproj | 18 +++ .../Controllers/WordsControllerTests.cs | 131 +++++++++++++++++ .../Repositories/WordRepositoryTests.cs | 61 ++++++++ .../Services/WordServiceTests.cs | 139 ++++++++++++++++++ tests/UnitTests/UnitTest1.cs | 10 -- tests/UnitTests/UnitTests.csproj | 23 --- 58 files changed, 1398 insertions(+), 295 deletions(-) delete mode 100644 .vscode/launch.json delete mode 100644 .vscode/settings.json create mode 100644 AGENTS.md delete mode 100644 CleanApi.sln create mode 100644 CleanApiStarter.slnx create mode 100644 database/migrations/V001__create_words_table.sql create mode 100644 docker-compose.yml delete mode 100644 src/Api/Api.csproj delete mode 100644 src/Api/Api.http delete mode 100644 src/Api/Program.cs delete mode 100644 src/Api/Properties/launchSettings.json delete mode 100644 src/Api/appsettings.Development.json delete mode 100644 src/Application/Application.csproj delete mode 100644 src/Application/Class1.cs create mode 100644 src/CleanApiStarter.Api/Api.http create mode 100644 src/CleanApiStarter.Api/CleanApiStarter.Api.csproj create mode 100644 src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs create mode 100644 src/CleanApiStarter.Api/GlobalUsings.cs create mode 100644 src/CleanApiStarter.Api/Program.cs create mode 100644 src/CleanApiStarter.Api/Properties/launchSettings.json create mode 100644 src/CleanApiStarter.Api/appsettings.Development.json rename src/{Api => CleanApiStarter.Api}/appsettings.json (100%) create mode 100644 src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj create mode 100644 src/CleanApiStarter.AppHost/Program.cs create mode 100644 src/CleanApiStarter.AppHost/Properties/launchSettings.json create mode 100644 src/CleanApiStarter.Application/CleanApiStarter.Application.csproj create mode 100644 src/CleanApiStarter.Application/DependencyInjection.cs create mode 100644 src/CleanApiStarter.Application/GlobalUsings.cs create mode 100644 src/CleanApiStarter.Application/Interfaces/IWordRepository.cs create mode 100644 src/CleanApiStarter.Application/Interfaces/IWordService.cs create mode 100644 src/CleanApiStarter.Application/Models/WordDto.cs create mode 100644 src/CleanApiStarter.Application/Services/WordService.cs create mode 100644 src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj create mode 100644 src/CleanApiStarter.AspNetCore/Extensions.cs create mode 100644 src/CleanApiStarter.AspNetCore/GlobalUsings.cs create mode 100644 src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj create mode 100644 src/CleanApiStarter.Domain/Entities/Word.cs create mode 100644 src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj create mode 100644 src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs create mode 100644 src/CleanApiStarter.Infrastructure/DependencyInjection.cs create mode 100644 src/CleanApiStarter.Infrastructure/GlobalUsings.cs create mode 100644 src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs delete mode 100644 src/Domain/Class1.cs delete mode 100644 src/Domain/Domain.csproj delete mode 100644 src/Infrastructure/Class1.cs delete mode 100644 src/Infrastructure/Infrastructure.csproj create mode 100644 tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj create mode 100644 tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs create mode 100644 tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs create mode 100644 tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs delete mode 100644 tests/UnitTests/UnitTest1.cs delete mode 100644 tests/UnitTests/UnitTests.csproj diff --git a/.editorconfig b/.editorconfig index 4a9952c..d552917 100644 --- a/.editorconfig +++ b/.editorconfig @@ -77,10 +77,13 @@ dotnet_remove_unnecessary_suppression_exclusions = none #### C# Coding Conventions #### [*.cs] -# var preferences -csharp_style_var_elsewhere = false:silent -csharp_style_var_for_built_in_types = false:silent -csharp_style_var_when_type_is_apparent = false:silent +# var preferences +csharp_style_var_elsewhere = false:warning +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = false:warning + +# Prefer explicit types instead of var +dotnet_diagnostic.IDE0008.severity = warning # Expression-bodied members csharp_style_expression_bodied_accessors = true:silent diff --git a/.gitignore b/.gitignore index 8a30d25..a3c8cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -132,6 +132,10 @@ _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user +# JetBrains IDE and Junie local assistant files +.idea/ +.junie/ + # TeamCity is a build add-in _TeamCity* diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 5c7247b..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [] -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index ebda1eb..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "dotnet.defaultSolution": "CleanApi.sln" -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8573ebd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,111 @@ +# Agent Instructions + +This repository is a Clean Architecture API starter template named `CleanApiStarter`. + +## Naming + +- Use `CleanApiStarter` for solution, project, assembly, and namespace naming. +- Do not introduce `CleanArchitecture` namespaces or assembly names. +- Keep project names fully qualified: + - `CleanApiStarter.Api` + - `CleanApiStarter.Application` + - `CleanApiStarter.Domain` + - `CleanApiStarter.Infrastructure` + - `CleanApiStarter.AppHost` + - `CleanApiStarter.AspNetCore` + - `CleanApiStarter.UnitTests` + +## Solution Structure + +- Keep clean architecture application layers under the `/src/` solution folder: + - API + - Application + - Domain + - Infrastructure +- Keep Aspire/runtime support projects under the `/src/Common/` solution folder in `CleanApiStarter.slnx`: + - AppHost + - AspNetCore +- Keep database scripts under top-level `database/migrations`. +- Keep important root files in solution items, including `README.md`, `docker-compose.yml`, `Directory.Build.props`, `Directory.Packages.props`, `.editorconfig`, `.gitignore`, and `global.json`. + +## Clean Architecture Rules + +- Dependencies point inward: + - `Domain` references nothing. + - `Application` references `Domain`. + - `Infrastructure` references `Application`. + - `Api` composes `Application`, `Infrastructure`, and `AspNetCore`. +- Keep repository interfaces in `Application`, not `Domain`. +- Keep database implementation details in `Infrastructure`. +- Do not move `IDatabaseConnectionFactory` into `Application` or `Domain`. +- Keep domain models persistence-agnostic. For example, `Word.Synonyms` stays `List`, even though Postgres stores it as `jsonb`. +- Private database projection types such as `WordRow` may live inside their repository when they are only used there. + +## API and Application Conventions + +- Use Scalar, not Swagger/Swashbuckle. +- Use `Microsoft.AspNetCore.OpenApi` with: + - `builder.Services.AddOpenApi();` + - `app.MapOpenApi();` + - `app.MapScalarApiReference();` +- Do not add `Swashbuckle.AspNetCore`. +- Cancellation tokens are explicit: + - Do not use `CancellationToken cancellationToken = default` in service or repository contracts. + - API actions should accept `CancellationToken cancellationToken` and pass it through. +- Use explicit local types. The `.editorconfig` prefers explicit types over `var`. +- Use project-level `GlobalUsings.cs`; avoid adding file-level `using` directives unless there is a very specific reason. + +## Database and Aspire + +- Use a single Postgres database: `postgres`. +- Do not create or reference a separate `wordlibrary` database. +- AppHost should expose the API connection as `ConnectionStrings__postgres`. +- `docker-compose.yml` should use `POSTGRES_DB=postgres`. +- Database schema scripts live in `database/migrations`, for example: + - `database/migrations/V001__create_words_table.sql` +- Do not add API startup database initialization such as `DbInitializer` or DbUp calls. Aspire/Docker init scripts own local schema creation. +- Docker Postgres init scripts run only on first volume creation. If scripts need to replay, delete the old volume. +- This repo uses `postgres:latest`. Because Postgres 18+ expects the data volume mounted at `/var/lib/postgresql`, do not mount the volume at `/var/lib/postgresql/data`. +- In Aspire, use a server resource name that does not conflict with the database resource name, for example: + - server resource: `postgres-server` + - database resource: `postgres` +- Aspire Postgres should use a volume mounted at `/var/lib/postgresql`. + +## ASP.NET Core Defaults + +- Keep `CleanApiStarter.AspNetCore`. +- It centralizes Aspire-friendly runtime defaults: + - OpenTelemetry traces, metrics, logs, and OTLP export + - health endpoints + - service discovery + - default HTTP client resilience +- API `Program.cs` should stay small and call: + - `builder.AddAspNetCoreDefaults();` + - `app.UseAspNetCoreDefaults();` + - `app.MapDefaultEndpoints();` +- Shared middleware such as HTTP request logging belongs in `CleanApiStarter.AspNetCore`, not duplicated inside each API project. +- Keep OpenTelemetry logs configured to include scopes, formatted messages, and parsed state values so structured message-template properties show up in Aspire. +- Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{WordId}` and `{WordCount}`. + +## API Style + +- Prefer Minimal APIs for this template. +- Keep `Program.cs` small by placing route groups in endpoint mapping classes such as `Api/Endpoints/WordEndpoints.cs`. +- Do not reintroduce MVC controllers unless the template intentionally changes direction. + +## Packages + +- Manage versions centrally in `Directory.Packages.props`. +- Keep `PackageVersion` items sorted alphabetically by `Include`. +- Do not add package versions directly in individual `.csproj` files. + +## Verification + +- After structural or package changes, run: + +```bash +dotnet restore CleanApiStarter.slnx --disable-parallel +dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal +``` + +- Aspire AppHost builds may need to run outside a sandbox because the Aspire SDK touches local runtime/process resources. diff --git a/CleanApi.sln b/CleanApi.sln deleted file mode 100644 index e7ee670..0000000 --- a/CleanApi.sln +++ /dev/null @@ -1,67 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B89C7703-FA6E-4F45-81D3-6591C0E97548}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api", "src\Api\Api.csproj", "{21EE4D82-D963-4832-B621-B8B5138377B7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Application", "src\Application\Application.csproj", "{A804F7DE-51BF-44CB-A2FA-680FC68571DB}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Domain", "src\Domain\Domain.csproj", "{3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "src\Infrastructure\Infrastructure.csproj", "{662A350A-2580-4B96-A1E6-C8C742E60F78}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{09BA79D0-7A13-419E-A21E-C32B3E7EAE0C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnitTests", "tests\UnitTests\UnitTests.csproj", "{3BE68B1A-FF8A-49C5-A787-6B26600D12C8}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F1E8E655-4E70-45DD-9EBA-7CFF831E84A9}" - ProjectSection(SolutionItems) = preProject - Directory.Build.props = Directory.Build.props - .editorconfig = .editorconfig - global.json = global.json - .gitignore = .gitignore - Directory.Packages.props = Directory.Packages.props - .config\dotnet-tools.json = .config\dotnet-tools.json - EndProjectSection -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {21EE4D82-D963-4832-B621-B8B5138377B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {21EE4D82-D963-4832-B621-B8B5138377B7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {21EE4D82-D963-4832-B621-B8B5138377B7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {21EE4D82-D963-4832-B621-B8B5138377B7}.Release|Any CPU.Build.0 = Release|Any CPU - {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A804F7DE-51BF-44CB-A2FA-680FC68571DB}.Release|Any CPU.Build.0 = Release|Any CPU - {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA}.Release|Any CPU.Build.0 = Release|Any CPU - {662A350A-2580-4B96-A1E6-C8C742E60F78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {662A350A-2580-4B96-A1E6-C8C742E60F78}.Debug|Any CPU.Build.0 = Debug|Any CPU - {662A350A-2580-4B96-A1E6-C8C742E60F78}.Release|Any CPU.ActiveCfg = Release|Any CPU - {662A350A-2580-4B96-A1E6-C8C742E60F78}.Release|Any CPU.Build.0 = Release|Any CPU - {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3BE68B1A-FF8A-49C5-A787-6B26600D12C8}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {21EE4D82-D963-4832-B621-B8B5138377B7} = {B89C7703-FA6E-4F45-81D3-6591C0E97548} - {A804F7DE-51BF-44CB-A2FA-680FC68571DB} = {B89C7703-FA6E-4F45-81D3-6591C0E97548} - {3E8AD1A4-B7A2-4FD6-A438-0E709E54C5CA} = {B89C7703-FA6E-4F45-81D3-6591C0E97548} - {662A350A-2580-4B96-A1E6-C8C742E60F78} = {B89C7703-FA6E-4F45-81D3-6591C0E97548} - {3BE68B1A-FF8A-49C5-A787-6B26600D12C8} = {09BA79D0-7A13-419E-A21E-C32B3E7EAE0C} - EndGlobalSection -EndGlobal diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx new file mode 100644 index 0000000..9b35a61 --- /dev/null +++ b/CleanApiStarter.slnx @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Directory.Build.props b/Directory.Build.props index 2b78872..f2acb86 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - net8.0 + net10.0 true enable diff --git a/Directory.Packages.props b/Directory.Packages.props index 3121cd2..2437245 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,16 +1,41 @@ - - - - true - - - - - - - - - - - + + + + true + 13.2.4 + 10.0.0 + 10.0.0 + 10.0.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index a12e93f..4c890cc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,75 @@ -# CleanApi -Dotnet API based on Clean Architecture +# CleanApiStarter + +A Clean Architecture API starter template for .NET, Aspire, PostgreSQL, and OpenTelemetry. + +The API uses Minimal APIs with endpoint mapping classes so `Program.cs` stays small while routes remain easy to find. + +## Learn OpenTelemetry with .NET Aspire + +This solution can run the existing API with a local PostgreSQL container through .NET Aspire. Aspire also provides the local dashboard where you can inspect logs, traces, metrics, and resource health. + +### Run with Aspire + +```bash +dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj +``` + +Open the Aspire dashboard URL printed in the terminal. The AppHost starts: + +- `api`: the ASP.NET Core API +- `postgres`: a local PostgreSQL container +- `postgres`: the API database +- `pgAdmin`: a browser UI for inspecting PostgreSQL + +The API receives its connection string from Aspire as `ConnectionStrings__postgres`. Running the API directly still uses `ConnectionStrings:DefaultConnection` from `src/CleanApiStarter.Api/appsettings.Development.json`, which matches the existing `docker-compose.yml`. + +Database scripts live in `database`. Aspire and Docker Compose copy `database/migrations` into the Postgres container init folder. Docker runs them only when the Postgres data directory is created for the first time. Because the AppHost uses the persistent `clean-api-starter-postgres-data` volume, delete that Docker volume if you need to replay the scripts from scratch. + +- `database/migrations`: versioned schema scripts that can also be reused by CI/CD migration tools such as Flyway. + +### What to Observe + +After the dashboard is running, send requests to the API through Scalar or another HTTP client. In the Aspire dashboard: + +- Logs show structured application and framework log entries. The word use cases emit fields such as `WordId`, `SynonymCount`, `WordCount`, and `UpdateSucceeded`. +- Request logs show method, path, response status code, and duration without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`. +- Traces show incoming HTTP requests and PostgreSQL commands from Npgsql. +- Metrics show ASP.NET Core, HTTP client, and .NET runtime measurements. +- Health checks expose `/health` and `/alive` in Development. + +### Coding Conventions + +Cancellation tokens are explicit at application and infrastructure boundaries. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. API actions should accept a `CancellationToken` parameter and pass it through the application and repository calls. + +Use structured logging message templates instead of interpolated log strings. Prefer stable property names such as `{WordId}` or `{WordCount}` so Aspire and OpenTelemetry can index and filter them. + +### Run with Docker Compose Only + +If you want to run PostgreSQL without Aspire: + +```bash +docker compose up -d +dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +``` + +### Troubleshooting Aspire Certificates + +If the Aspire dashboard logs an `UntrustedRoot` error while calling gRPC services, or the AppHost prints `No trusted Aspire development certificate was found`, trust the local ASP.NET Core development certificate: + +```bash +dotnet dev-certs https --check --trust +``` + +If the certificate is missing or untrusted, reset it and trust it again: + +```bash +dotnet dev-certs https --clean +dotnet dev-certs https --trust +dotnet dev-certs https --check --trust +``` + +On macOS, accept the Keychain prompt and enter your password if requested. Then close and reopen the browser, and start Aspire again: + +```bash +dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj +``` diff --git a/database/migrations/V001__create_words_table.sql b/database/migrations/V001__create_words_table.sql new file mode 100644 index 0000000..48ffe64 --- /dev/null +++ b/database/migrations/V001__create_words_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS words ( + id UUID PRIMARY KEY, + text VARCHAR(100) NOT NULL, + meaning TEXT NOT NULL, + synonyms JSONB NOT NULL, + usage_example TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NULL +); + +CREATE INDEX IF NOT EXISTS idx_words_text ON words(text); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1536d3e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' + +services: + postgres: + image: postgres:latest + container_name: clean-api-starter-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql + - ./database/migrations:/docker-entrypoint-initdb.d:ro + +volumes: + postgres-data: diff --git a/global.json b/global.json index 5c4e745..8994a62 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ -{ - "sdk": { - "version": "8.0.100", - "rollForward": "latestFeature" - } +{ + "sdk": { + "version": "10.0.203", + "rollForward": "latestFeature" + } } \ No newline at end of file diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj deleted file mode 100644 index a2d2523..0000000 --- a/src/Api/Api.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net8.0 - enable - enable - - - - - - - - diff --git a/src/Api/Api.http b/src/Api/Api.http deleted file mode 100644 index 7a2b9ed..0000000 --- a/src/Api/Api.http +++ /dev/null @@ -1,6 +0,0 @@ -@Api_HostAddress = http://localhost:5219 - -GET {{Api_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/src/Api/Program.cs b/src/Api/Program.cs deleted file mode 100644 index fda9a7d..0000000 --- a/src/Api/Program.cs +++ /dev/null @@ -1,44 +0,0 @@ -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.UseSwagger(); - app.UseSwaggerUI(); -} - -app.UseHttpsRedirection(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => -{ - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; -}) -.WithName("GetWeatherForecast") -.WithOpenApi(); - -app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} diff --git a/src/Api/Properties/launchSettings.json b/src/Api/Properties/launchSettings.json deleted file mode 100644 index fa2d512..0000000 --- a/src/Api/Properties/launchSettings.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:46994", - "sslPort": 44360 - } - }, - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "http://localhost:5219", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "swagger", - "applicationUrl": "https://localhost:7285;http://localhost:5219", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "swagger", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json deleted file mode 100644 index ff66ba6..0000000 --- a/src/Api/appsettings.Development.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - } -} diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj deleted file mode 100644 index bb23fb7..0000000 --- a/src/Application/Application.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net8.0 - enable - enable - - - diff --git a/src/Application/Class1.cs b/src/Application/Class1.cs deleted file mode 100644 index f1d69f9..0000000 --- a/src/Application/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Application; - -public class Class1 -{ - -} diff --git a/src/CleanApiStarter.Api/Api.http b/src/CleanApiStarter.Api/Api.http new file mode 100644 index 0000000..f420003 --- /dev/null +++ b/src/CleanApiStarter.Api/Api.http @@ -0,0 +1,18 @@ +@CleanApiStarter_Api_HostAddress = http://localhost:5219 + +GET {{CleanApiStarter_Api_HostAddress}}/api/words +Accept: application/json + +### + +POST {{CleanApiStarter_Api_HostAddress}}/api/words +Content-Type: application/json + +{ + "text": "observable", + "meaning": "Able to be noticed, measured, or monitored.", + "synonyms": ["visible", "measurable"], + "usageExample": "OpenTelemetry makes API behavior observable." +} + +### diff --git a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj new file mode 100644 index 0000000..6014433 --- /dev/null +++ b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj @@ -0,0 +1,19 @@ + + + + CleanApiStarter.Api + CleanApiStarter.Api + + + + + + + + + + + + + + diff --git a/src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs b/src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs new file mode 100644 index 0000000..e5fdfb7 --- /dev/null +++ b/src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs @@ -0,0 +1,77 @@ +namespace CleanApiStarter.Api.Endpoints; + +public static class WordEndpoints +{ + public static IEndpointRouteBuilder MapWordEndpoints(this IEndpointRouteBuilder endpoints) + { + RouteGroupBuilder group = endpoints.MapGroup("/api/words") + .WithTags("Words"); + + group.MapPost("/", CreateWord) + .WithName(nameof(CreateWord)); + + group.MapGet("/{id:guid}", GetWord) + .WithName(nameof(GetWord)); + + group.MapGet("/", GetAllWords) + .WithName(nameof(GetAllWords)); + + group.MapPut("/{id:guid}", UpdateWord) + .WithName(nameof(UpdateWord)); + + group.MapDelete("/{id:guid}", DeleteWord) + .WithName(nameof(DeleteWord)); + + return endpoints; + } + + private static async Task CreateWord( + CreateWordDto wordDto, + IWordService wordService, + CancellationToken cancellationToken) + { + Guid id = await wordService.AddWordAsync(wordDto, cancellationToken); + + return Results.Created($"/api/words/{id}", id); + } + + private static async Task GetWord( + Guid id, + IWordService wordService, + CancellationToken cancellationToken) + { + WordDto? word = await wordService.GetWordByIdAsync(id, cancellationToken); + + return word == null ? Results.NotFound() : Results.Ok(word); + } + + private static async Task GetAllWords( + IWordService wordService, + CancellationToken cancellationToken) + { + IEnumerable words = await wordService.GetAllWordsAsync(cancellationToken); + + return Results.Ok(words); + } + + private static async Task UpdateWord( + Guid id, + CreateWordDto wordDto, + IWordService wordService, + CancellationToken cancellationToken) + { + bool success = await wordService.UpdateWordAsync(id, wordDto, cancellationToken); + + return success ? Results.NoContent() : Results.NotFound(); + } + + private static async Task DeleteWord( + Guid id, + IWordService wordService, + CancellationToken cancellationToken) + { + bool success = await wordService.DeleteWordAsync(id, cancellationToken); + + return success ? Results.NoContent() : Results.NotFound(); + } +} diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs new file mode 100644 index 0000000..30dfefd --- /dev/null +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -0,0 +1,13 @@ +// Global using directives + +global using CleanApiStarter.Api.Endpoints; +global using CleanApiStarter.Application; +global using CleanApiStarter.Application.Interfaces; +global using CleanApiStarter.Application.Models; +global using CleanApiStarter.AspNetCore; +global using CleanApiStarter.Infrastructure; + +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Routing; + +global using Scalar.AspNetCore; diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs new file mode 100644 index 0000000..1f4ec8e --- /dev/null +++ b/src/CleanApiStarter.Api/Program.cs @@ -0,0 +1,25 @@ +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.AddAspNetCoreDefaults(); + +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddOpenApi(); + +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); + app.MapScalarApiReference(); +} + +app.UseAspNetCoreDefaults(); +app.UseHttpsRedirection(); +app.MapWordEndpoints(); +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/src/CleanApiStarter.Api/Properties/launchSettings.json b/src/CleanApiStarter.Api/Properties/launchSettings.json new file mode 100644 index 0000000..a6d24cd --- /dev/null +++ b/src/CleanApiStarter.Api/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar", + "applicationUrl": "https://localhost:7285", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/CleanApiStarter.Api/appsettings.Development.json b/src/CleanApiStarter.Api/appsettings.Development.json new file mode 100644 index 0000000..7de2b6c --- /dev/null +++ b/src/CleanApiStarter.Api/appsettings.Development.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/Api/appsettings.json b/src/CleanApiStarter.Api/appsettings.json similarity index 100% rename from src/Api/appsettings.json rename to src/CleanApiStarter.Api/appsettings.json diff --git a/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj b/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj new file mode 100644 index 0000000..d40f932 --- /dev/null +++ b/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj @@ -0,0 +1,19 @@ + + + + Exe + CleanApiStarter.AppHost + CleanApiStarter.AppHost + clean-api-starter-apphost + + + + + + + + + + + + diff --git a/src/CleanApiStarter.AppHost/Program.cs b/src/CleanApiStarter.AppHost/Program.cs new file mode 100644 index 0000000..823eb18 --- /dev/null +++ b/src/CleanApiStarter.AppHost/Program.cs @@ -0,0 +1,19 @@ +IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); + +string databasePath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "database")); +string databaseMigrationsPath = Path.Combine(databasePath, "migrations"); + +IResourceBuilder postgresServer = builder.AddPostgres("postgres-server") + .WithImageTag("latest") + .WithVolume("clean-api-starter-postgres-data", "/var/lib/postgresql") + .WithInitFiles(databaseMigrationsPath) + .WithPgAdmin(); + +IResourceBuilder database = postgresServer.AddDatabase("postgres") + .WithCreationScript("SELECT 1;"); + +builder.AddProject("api") + .WithReference(database) + .WaitFor(database); + +builder.Build().Run(); diff --git a/src/CleanApiStarter.AppHost/Properties/launchSettings.json b/src/CleanApiStarter.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000..046e309 --- /dev/null +++ b/src/CleanApiStarter.AppHost/Properties/launchSettings.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17134;http://localhost:15170", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21030", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22057" + } + } + } +} diff --git a/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj b/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj new file mode 100644 index 0000000..8b8bb4d --- /dev/null +++ b/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj @@ -0,0 +1,16 @@ + + + + CleanApiStarter.Application + CleanApiStarter.Application + + + + + + + + + + + diff --git a/src/CleanApiStarter.Application/DependencyInjection.cs b/src/CleanApiStarter.Application/DependencyInjection.cs new file mode 100644 index 0000000..53567a8 --- /dev/null +++ b/src/CleanApiStarter.Application/DependencyInjection.cs @@ -0,0 +1,10 @@ +namespace CleanApiStarter.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddScoped(); + return services; + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/src/CleanApiStarter.Application/GlobalUsings.cs new file mode 100644 index 0000000..f169a6b --- /dev/null +++ b/src/CleanApiStarter.Application/GlobalUsings.cs @@ -0,0 +1,7 @@ +global using CleanApiStarter.Application.Interfaces; +global using CleanApiStarter.Application.Models; +global using CleanApiStarter.Application.Services; +global using CleanApiStarter.Domain.Entities; + +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; diff --git a/src/CleanApiStarter.Application/Interfaces/IWordRepository.cs b/src/CleanApiStarter.Application/Interfaces/IWordRepository.cs new file mode 100644 index 0000000..c38a9c3 --- /dev/null +++ b/src/CleanApiStarter.Application/Interfaces/IWordRepository.cs @@ -0,0 +1,10 @@ +namespace CleanApiStarter.Application.Interfaces; + +public interface IWordRepository +{ + Task AddWordAsync(Word word, CancellationToken cancellationToken); + Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); + Task> GetAllWordsAsync(CancellationToken cancellationToken); + Task UpdateWordAsync(Word word, CancellationToken cancellationToken); + Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Interfaces/IWordService.cs b/src/CleanApiStarter.Application/Interfaces/IWordService.cs new file mode 100644 index 0000000..46f0c59 --- /dev/null +++ b/src/CleanApiStarter.Application/Interfaces/IWordService.cs @@ -0,0 +1,10 @@ +namespace CleanApiStarter.Application.Interfaces; + +public interface IWordService +{ + Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken); + Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); + Task> GetAllWordsAsync(CancellationToken cancellationToken); + Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken); + Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); +} diff --git a/src/CleanApiStarter.Application/Models/WordDto.cs b/src/CleanApiStarter.Application/Models/WordDto.cs new file mode 100644 index 0000000..645bf2c --- /dev/null +++ b/src/CleanApiStarter.Application/Models/WordDto.cs @@ -0,0 +1,18 @@ +namespace CleanApiStarter.Application.Models; + +public class WordDto +{ + public Guid Id { get; set; } + public string Text { get; set; } = string.Empty; + public string Meaning { get; set; } = string.Empty; + public List Synonyms { get; set; } = new(); + public string UsageExample { get; set; } = string.Empty; +} + +public class CreateWordDto +{ + public string Text { get; set; } = string.Empty; + public string Meaning { get; set; } = string.Empty; + public List Synonyms { get; set; } = new(); + public string UsageExample { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Services/WordService.cs b/src/CleanApiStarter.Application/Services/WordService.cs new file mode 100644 index 0000000..ffa6d09 --- /dev/null +++ b/src/CleanApiStarter.Application/Services/WordService.cs @@ -0,0 +1,105 @@ +namespace CleanApiStarter.Application.Services; + +public class WordService(IWordRepository wordRepository, ILogger logger) : IWordService +{ + public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken) + { + Word word = new() + { + Id = Guid.NewGuid(), + Text = wordDto.Text, + Meaning = wordDto.Meaning, + Synonyms = wordDto.Synonyms, + UsageExample = wordDto.UsageExample, + CreatedAt = DateTime.UtcNow + }; + + logger.LogInformation( + "Creating word {WordId} with {SynonymCount} synonyms", + word.Id, + word.Synonyms.Count); + + Guid wordId = await wordRepository.AddWordAsync(word, cancellationToken); + + logger.LogInformation("Created word {WordId}", wordId); + + return wordId; + } + + public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken) + { + Word? word = await wordRepository.GetWordByIdAsync(id, cancellationToken); + + if (word == null) + { + logger.LogWarning("Word {WordId} was not found", id); + return null; + } + + logger.LogInformation("Retrieved word {WordId}", id); + + return MapToDto(word); + } + + public async Task> GetAllWordsAsync(CancellationToken cancellationToken) + { + IEnumerable words = await wordRepository.GetAllWordsAsync(cancellationToken); + Word[] wordList = words.ToArray(); + + logger.LogInformation("Retrieved {WordCount} words", wordList.Length); + + return wordList.Select(MapToDto); + } + + public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken) + { + Word? existingWord = await wordRepository.GetWordByIdAsync(id, cancellationToken); + if (existingWord == null) + { + logger.LogWarning("Word {WordId} could not be updated because it was not found", id); + return false; + } + + existingWord.Text = wordDto.Text; + existingWord.Meaning = wordDto.Meaning; + existingWord.Synonyms = wordDto.Synonyms; + existingWord.UsageExample = wordDto.UsageExample; + existingWord.UpdatedAt = DateTime.UtcNow; + + bool updated = await wordRepository.UpdateWordAsync(existingWord, cancellationToken); + + logger.LogInformation( + "Updated word {WordId}; success: {UpdateSucceeded}", + id, + updated); + + return updated; + } + + public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken) + { + bool deleted = await wordRepository.DeleteWordAsync(id, cancellationToken); + + if (!deleted) + { + logger.LogWarning("Word {WordId} could not be deleted because it was not found", id); + return false; + } + + logger.LogInformation("Deleted word {WordId}", id); + + return true; + } + + private static WordDto MapToDto(Word word) + { + return new WordDto + { + Id = word.Id, + Text = word.Text, + Meaning = word.Meaning, + Synonyms = word.Synonyms, + UsageExample = word.UsageExample + }; + } +} diff --git a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj new file mode 100644 index 0000000..73f91c4 --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj @@ -0,0 +1,21 @@ + + + + CleanApiStarter.AspNetCore + CleanApiStarter.AspNetCore + + + + + + + + + + + + + + + + diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/src/CleanApiStarter.AspNetCore/Extensions.cs new file mode 100644 index 0000000..89738df --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/Extensions.cs @@ -0,0 +1,97 @@ +namespace CleanApiStarter.AspNetCore; + +public static class Extensions +{ + public static IHostApplicationBuilder AddAspNetCoreDefaults(this IHostApplicationBuilder builder) + { + builder.ConfigureOpenTelemetry(); + builder.Logging.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information); + + builder.Services.AddHttpLogging(options => + { + options.LoggingFields = + HttpLoggingFields.RequestMethod | + HttpLoggingFields.RequestPath | + HttpLoggingFields.ResponseStatusCode | + HttpLoggingFields.Duration; + }); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication UseAspNetCoreDefaults(this WebApplication app) + { + app.UseHttpLogging(); + + return app; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("live") + }); + } + + return app; + } + + private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + logging.ParseStateValues = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddNpgsqlInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddNpgsql(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) + { + bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.Configure(logging => logging.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); + } + + return builder; + } +} diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs new file mode 100644 index 0000000..c47d19a --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs @@ -0,0 +1,14 @@ +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Diagnostics.HealthChecks; +global using Microsoft.AspNetCore.HttpLogging; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Diagnostics.HealthChecks; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; + +global using Npgsql; + +global using OpenTelemetry; +global using OpenTelemetry.Logs; +global using OpenTelemetry.Metrics; +global using OpenTelemetry.Trace; diff --git a/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj b/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj new file mode 100644 index 0000000..115c60c --- /dev/null +++ b/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj @@ -0,0 +1,6 @@ + + + CleanApiStarter.Domain + CleanApiStarter.Domain + + \ No newline at end of file diff --git a/src/CleanApiStarter.Domain/Entities/Word.cs b/src/CleanApiStarter.Domain/Entities/Word.cs new file mode 100644 index 0000000..177c398 --- /dev/null +++ b/src/CleanApiStarter.Domain/Entities/Word.cs @@ -0,0 +1,12 @@ +namespace CleanApiStarter.Domain.Entities; + +public class Word +{ + public Guid Id { get; init; } + public string Text { get; set; } = string.Empty; + public string Meaning { get; set; } = string.Empty; + public List Synonyms { get; set; } = []; + public string UsageExample { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj new file mode 100644 index 0000000..772f2b1 --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj @@ -0,0 +1,17 @@ + + + + CleanApiStarter.Infrastructure + CleanApiStarter.Infrastructure + + + + + + + + + + + + diff --git a/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs b/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs new file mode 100644 index 0000000..c406b04 --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs @@ -0,0 +1,14 @@ +namespace CleanApiStarter.Infrastructure.Database; + +public interface IDatabaseConnectionFactory +{ + IDbConnection CreateConnection(); +} + +public class PostgresConnectionFactory(string connectionString) : IDatabaseConnectionFactory +{ + public IDbConnection CreateConnection() + { + return new NpgsqlConnection(connectionString); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..a1b6aa1 --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs @@ -0,0 +1,24 @@ +namespace CleanApiStarter.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + // Register database connection factory + string? connectionString = + configuration.GetConnectionString("postgres") ?? + configuration.GetConnectionString("DefaultConnection"); + + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException("Database connection string 'postgres' or 'DefaultConnection' is missing."); + } + + services.AddSingleton(_ => new PostgresConnectionFactory(connectionString!)); + + // Register repositories + services.AddScoped(); + + return services; + } +} diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs new file mode 100644 index 0000000..3b36d17 --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs @@ -0,0 +1,14 @@ +global using System.Data; +global using System.Text.Json; + +global using CleanApiStarter.Application.Interfaces; +global using CleanApiStarter.Domain.Entities; +global using CleanApiStarter.Infrastructure.Database; +global using CleanApiStarter.Infrastructure.Repositories; + +global using Dapper; + +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; + +global using Npgsql; \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs new file mode 100644 index 0000000..d255b5e --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs @@ -0,0 +1,121 @@ +namespace CleanApiStarter.Infrastructure.Repositories; + +public class WordRepository(IDatabaseConnectionFactory connectionFactory) : IWordRepository +{ + public async Task AddWordAsync(Word word, CancellationToken cancellationToken) + { + const string sql = + """ + INSERT INTO words (id, text, meaning, synonyms, usage_example, created_at, updated_at) + VALUES (@Id, @Text, @Meaning, @Synonyms::jsonb, @UsageExample, @CreatedAt, @UpdatedAt) + RETURNING id + """; + + using IDbConnection connection = connectionFactory.CreateConnection(); + + var parameters = new + { + word.Id, + word.Text, + word.Meaning, + Synonyms = JsonSerializer.Serialize(word.Synonyms), + word.UsageExample, + word.CreatedAt, + word.UpdatedAt + }; + + Guid id = await connection.ExecuteScalarAsync(sql, parameters); + return id; + } + + public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken) + { + const string sql = + """ + SELECT id, text, meaning, synonyms::text as SynonymsJson, usage_example as UsageExample, created_at as CreatedAt, updated_at as UpdatedAt + FROM words + WHERE id = @Id + """; + + using IDbConnection connection = connectionFactory.CreateConnection(); + WordRow? word = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); + + return word?.ToDomain(); + } + + public async Task> GetAllWordsAsync(CancellationToken cancellationToken) + { + const string sql = + """ + SELECT id, text, meaning, synonyms::text as SynonymsJson, usage_example as UsageExample, created_at as CreatedAt, updated_at as UpdatedAt + FROM words + """; + + using IDbConnection connection = connectionFactory.CreateConnection(); + IEnumerable words = await connection.QueryAsync(sql); + + return words.Select(word => word.ToDomain()); + } + + public async Task UpdateWordAsync(Word word, CancellationToken cancellationToken) + { + const string sql = + """ + UPDATE words + SET text = @Text, + meaning = @Meaning, + synonyms = @Synonyms, + usage_example = @UsageExample, + updated_at = @UpdatedAt + WHERE id = @Id + """; + + using IDbConnection connection = connectionFactory.CreateConnection(); + var parameters = new + { + word.Id, + word.Text, + word.Meaning, + Synonyms = JsonSerializer.Serialize(word.Synonyms), + word.UsageExample, + word.UpdatedAt + }; + + int rowsAffected = await connection.ExecuteAsync(sql, parameters); + return rowsAffected > 0; + } + + public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken) + { + const string sql = "DELETE FROM words WHERE id = @Id"; + + using IDbConnection connection = connectionFactory.CreateConnection(); + int rowsAffected = await connection.ExecuteAsync(sql, new { Id = id }); + return rowsAffected > 0; + } + + private sealed class WordRow + { + public Guid Id { get; init; } + public string Text { get; init; } = string.Empty; + public string Meaning { get; init; } = string.Empty; + public string SynonymsJson { get; init; } = string.Empty; + public string UsageExample { get; init; } = string.Empty; + public DateTime CreatedAt { get; init; } + public DateTime? UpdatedAt { get; init; } + + public Word ToDomain() + { + return new Word + { + Id = Id, + Text = Text, + Meaning = Meaning, + Synonyms = JsonSerializer.Deserialize>(SynonymsJson) ?? [], + UsageExample = UsageExample, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt + }; + } + } +} diff --git a/src/Domain/Class1.cs b/src/Domain/Class1.cs deleted file mode 100644 index dfc5368..0000000 --- a/src/Domain/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Domain; - -public class Class1 -{ - -} diff --git a/src/Domain/Domain.csproj b/src/Domain/Domain.csproj deleted file mode 100644 index bb23fb7..0000000 --- a/src/Domain/Domain.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net8.0 - enable - enable - - - diff --git a/src/Infrastructure/Class1.cs b/src/Infrastructure/Class1.cs deleted file mode 100644 index 301b928..0000000 --- a/src/Infrastructure/Class1.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Infrastructure; - -public class Class1 -{ - -} diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj deleted file mode 100644 index bb23fb7..0000000 --- a/src/Infrastructure/Infrastructure.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - - net8.0 - enable - enable - - - diff --git a/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj b/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj new file mode 100644 index 0000000..f36e667 --- /dev/null +++ b/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj @@ -0,0 +1,18 @@ + + + CleanApiStarter.UnitTests + CleanApiStarter.UnitTests + + + + + + + + + + + + + + diff --git a/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs b/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs new file mode 100644 index 0000000..356aa00 --- /dev/null +++ b/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs @@ -0,0 +1,131 @@ +// using CleanApiStarter.Api.Controllers; +// using CleanApiStarter.Application.Interfaces; +// using CleanApiStarter.Application.Models; +// using AutoFixture.Xunit3; +// using FluentAssertions; +// using Microsoft.AspNetCore.Mvc; +// using NSubstitute; +// using Xunit; +// +// namespace CleanApiStarter.UnitTests.Controllers; +// +// public class WordsControllerTests +// { +// private readonly IWordService _wordService; +// private readonly WordsController _sut; +// +// public WordsControllerTests() +// { +// _wordService = Substitute.For(); +// _sut = new WordsController(_wordService); +// } +// +// [Theory, AutoData] +// public async Task CreateWord_ShouldReturnCreatedAtAction(CreateWordDto wordDto, Guid wordId) +// { +// // Arrange +// _wordService.AddWordAsync(wordDto).Returns(wordId); +// +// // Act +// var result = await _sut.CreateWord(wordDto); +// +// // Assert +// var createdAtActionResult = result.Result.Should().BeOfType().Subject; +// createdAtActionResult.ActionName.Should().Be(nameof(WordsController.GetWord)); +// createdAtActionResult.RouteValues!.Should().ContainKey("id").WhoseValue.Should().Be(wordId); +// createdAtActionResult.Value.Should().Be(wordId); +// } +// +// [Theory, AutoData] +// public async Task GetWord_WhenWordExists_ShouldReturnOkWithWord(Guid wordId, WordDto wordDto) +// { +// // Arrange +// _wordService.GetWordByIdAsync(wordId).Returns(wordDto); +// +// // Act +// var result = await _sut.GetWord(wordId); +// +// // Assert +// var okResult = result.Result.Should().BeOfType().Subject; +// okResult.Value.Should().Be(wordDto); +// } +// +// [Theory, AutoData] +// public async Task GetWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId) +// { +// // Arrange +// _wordService.GetWordByIdAsync(wordId).Returns((WordDto?)null); +// +// // Act +// var result = await _sut.GetWord(wordId); +// +// // Assert +// result.Result.Should().BeOfType(); +// } +// +// [Theory, AutoData] +// public async Task GetAllWords_ShouldReturnOkWithAllWords(List words) +// { +// // Arrange +// _wordService.GetAllWordsAsync().Returns(words); +// +// // Act +// var result = await _sut.GetAllWords(); +// +// // Assert +// var okResult = result.Result.Should().BeOfType().Subject; +// okResult.Value.Should().Be(words); +// } +// +// [Theory, AutoData] +// public async Task UpdateWord_WhenWordExists_ShouldReturnNoContent(Guid wordId, CreateWordDto updateDto) +// { +// // Arrange +// _wordService.UpdateWordAsync(wordId, updateDto).Returns(true); +// +// // Act +// var result = await _sut.UpdateWord(wordId, updateDto); +// +// // Assert +// result.Should().BeOfType(); +// } +// +// [Theory, AutoData] +// public async Task UpdateWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId, CreateWordDto updateDto) +// { +// // Arrange +// _wordService.UpdateWordAsync(wordId, updateDto).Returns(false); +// +// // Act +// var result = await _sut.UpdateWord(wordId, updateDto); +// +// // Assert +// result.Should().BeOfType(); +// } +// +// [Theory, AutoData] +// public async Task DeleteWord_WhenWordExists_ShouldReturnNoContent(Guid wordId) +// { +// // Arrange +// _wordService.DeleteWordAsync(wordId).Returns(true); +// +// // Act +// var result = await _sut.DeleteWord(wordId); +// +// // Assert +// result.Should().BeOfType(); +// } +// +// [Theory, AutoData] +// public async Task DeleteWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId) +// { +// // Arrange +// _wordService.DeleteWordAsync(wordId).Returns(false); +// +// // Act +// var result = await _sut.DeleteWord(wordId); +// +// // Assert +// result.Should().BeOfType(); +// } +// } \ No newline at end of file diff --git a/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs b/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs new file mode 100644 index 0000000..50d024d --- /dev/null +++ b/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs @@ -0,0 +1,61 @@ +// using System.Data; +// using AutoFixture.Xunit3; +// using CleanApiStarter.Domain.Entities; +// using FluentAssertions; +// using CleanApiStarter.Infrastructure.Database; +// using CleanApiStarter.Infrastructure.Repositories; +// using NSubstitute; +// using Xunit; +// +// namespace CleanApiStarter.UnitTests.Repositories; +// +// public class WordRepositoryTests +// { +// private readonly IDatabaseConnectionFactory _connectionFactory; +// private readonly IDbConnection _connection; +// private readonly WordRepository _sut; +// +// public WordRepositoryTests() +// { +// _connection = Substitute.For(); +// _connectionFactory = Substitute.For(); +// _connectionFactory.CreateConnection().Returns(_connection); +// +// _sut = new WordRepository(_connectionFactory); +// } +// +// [Theory, AutoData] +// public async Task AddWordAsync_ShouldOpenConnectionAndExecuteCommand(Word word) +// { +// // This test is a basic structure to show how to mock Dapper +// // Full implementation would require more complex mocking of connection and command +// +// // Arrange +// var expectedId = word.Id; +// +// // Act +// await _sut.AddWordAsync(word); +// +// // Assert +// _connectionFactory.Received(1).CreateConnection(); +// } +// +// [Theory, AutoData] +// public async Task GetWordByIdAsync_ShouldOpenConnectionAndExecuteQuery(Guid id) +// { +// // This test is a basic structure to show how to mock Dapper +// // Full implementation would require more complex mocking of connection and query +// +// // Arrange +// +// // Act +// await _sut.GetWordByIdAsync(id); +// +// // Assert +// _connectionFactory.Received(1).CreateConnection(); +// } +// +// // Additional tests would follow the same pattern +// // For a real implementation, you would need to mock Dapper's extension methods +// // which is more complex and may require a test database or more sophisticated mocking +// } \ No newline at end of file diff --git a/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs b/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs new file mode 100644 index 0000000..601e4a5 --- /dev/null +++ b/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs @@ -0,0 +1,139 @@ +// using CleanApiStarter.Application.Interfaces; +// using CleanApiStarter.Application.Models; +// using CleanApiStarter.Application.Services; +// +// using CleanApiStarter.Domain.Entities; +// +// using NSubstitute; +// +// using Shouldly; +// +// using Xunit; +// +// namespace CleanApiStarter.UnitTests.Services; +// +// public class WordServiceTests +// { +// private readonly IWordRepository _wordRepository; +// private readonly WordService _sut; +// +// public WordServiceTests() +// { +// _wordRepository = Substitute.For(); +// _sut = new WordService(_wordRepository); +// } +// +// [Theory, AutoData] +// public async Task AddWordAsync_ShouldMapDtoToDomainAndCallRepository(CreateWordDto wordDto) +// { +// // Arrange +// var expectedId = Guid.NewGuid(); +// _wordRepository.AddWordAsync(Arg.Any()).Returns(expectedId); +// +// // Act +// var result = await _sut.AddWordAsync(wordDto); +// +// // Assert +// result.ShouldBe(expectedId); +// await _wordRepository.Received(1).AddWordAsync(Arg.Is(w => +// w.Text == wordDto.Text && +// w.Meaning == wordDto.Meaning && +// w.Synonyms.SequenceEqual(wordDto.Synonyms) && +// w.UsageExample == wordDto.UsageExample)); +// } +// +// [Theory, AutoData] +// public async Task GetWordByIdAsync_WhenWordExists_ShouldReturnMappedDto(Guid id, Word word) +// { +// // Arrange +// _wordRepository.GetWordByIdAsync(id).Returns(word); +// +// // Act +// var result = await _sut.GetWordByIdAsync(id); +// +// // Assert +// result.Should().NotBeNull(); +// result!.Id.Should().Be(word.Id); +// result.Text.Should().Be(word.Text); +// result.Meaning.Should().Be(word.Meaning); +// result.Synonyms.Should().BeEquivalentTo(word.Synonyms); +// result.UsageExample.Should().Be(word.UsageExample); +// } +// +// [Theory, AutoData] +// public async Task GetWordByIdAsync_WhenWordDoesNotExist_ShouldReturnNull(Guid id) +// { +// // Arrange +// _wordRepository.GetWordByIdAsync(id).Returns((Word?)null); +// +// // Act +// var result = await _sut.GetWordByIdAsync(id); +// +// // Assert +// result.Should().BeNull(); +// } +// +// [Theory, AutoData] +// public async Task GetAllWordsAsync_ShouldReturnMappedDtos(List words) +// { +// // Arrange +// _wordRepository.GetAllWordsAsync().Returns(words); +// +// // Act +// var result = await _sut.GetAllWordsAsync(); +// +// // Assert +// result.Should().HaveCount(words.Count); +// result.Select(dto => dto.Id).Should().BeEquivalentTo(words.Select(w => w.Id)); +// } +// +// [Theory, AutoData] +// public async Task UpdateWordAsync_WhenWordExists_ShouldUpdateAndReturnTrue(Guid id, CreateWordDto updateDto, Word existingWord) +// { +// // Arrange +// existingWord.Id = id; +// _wordRepository.GetWordByIdAsync(id).Returns(existingWord); +// _wordRepository.UpdateWordAsync(Arg.Any()).Returns(true); +// +// // Act +// var result = await _sut.UpdateWordAsync(id, updateDto); +// +// // Assert +// result.Should().BeTrue(); +// await _wordRepository.Received(1).UpdateWordAsync(Arg.Is(w => +// w.Id == id && +// w.Text == updateDto.Text && +// w.Meaning == updateDto.Meaning && +// w.Synonyms.SequenceEqual(updateDto.Synonyms) && +// w.UsageExample == updateDto.UsageExample && +// w.UpdatedAt != null)); +// } +// +// [Theory, AutoData] +// public async Task UpdateWordAsync_WhenWordDoesNotExist_ShouldReturnFalse(Guid id, CreateWordDto updateDto) +// { +// // Arrange +// _wordRepository.GetWordByIdAsync(id).Returns((Word?)null); +// +// // Act +// var result = await _sut.UpdateWordAsync(id, updateDto); +// +// // Assert +// result.Should().BeFalse(); +// await _wordRepository.DidNotReceive().UpdateWordAsync(Arg.Any()); +// } +// +// [Theory, AutoData] +// public async Task DeleteWordAsync_ShouldCallRepositoryAndReturnResult(Guid id, bool expectedResult) +// { +// // Arrange +// _wordRepository.DeleteWordAsync(id).Returns(expectedResult); +// +// // Act +// var result = await _sut.DeleteWordAsync(id); +// +// // Assert +// result.Should().Be(expectedResult); +// await _wordRepository.Received(1).DeleteWordAsync(id); +// } +// } \ No newline at end of file diff --git a/tests/UnitTests/UnitTest1.cs b/tests/UnitTests/UnitTest1.cs deleted file mode 100644 index 791524d..0000000 --- a/tests/UnitTests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace UnitTests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} \ No newline at end of file diff --git a/tests/UnitTests/UnitTests.csproj b/tests/UnitTests/UnitTests.csproj deleted file mode 100644 index a1d45f6..0000000 --- a/tests/UnitTests/UnitTests.csproj +++ /dev/null @@ -1,23 +0,0 @@ - - - - net8.0 - enable - enable - - false - true - - - - - - - - - - - - - - From d911cc4da87b135bc73f3e30cab25d0ee2d216ff Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:00:22 +0800 Subject: [PATCH 04/33] introduce versioned endpoint groups, configuration project, and runtime defaults for CleanApiStarter API --- AGENTS.md | 23 ++- CleanApiStarter.slnx | 1 + Directory.Packages.props | 8 +- README.md | 9 +- src/CleanApiStarter.Api/Api.http | 10 +- .../{WordEndpoints.cs => V1/Words.cs} | 33 ++-- src/CleanApiStarter.Api/Endpoints/V2/Words.cs | 27 +++ src/CleanApiStarter.Api/GlobalUsings.cs | 4 +- src/CleanApiStarter.Api/Program.cs | 21 ++- .../appsettings.Development.json | 8 +- src/CleanApiStarter.AppHost/Program.cs | 32 ++-- .../GlobalUsings.cs | 10 +- .../Interfaces/IWordService.cs | 16 +- .../Services/WordService.cs | 174 +++++++++--------- .../AspNetCoreDefaultServices.cs | 52 ++++++ .../CleanApiStarter.AspNetCore.csproj | 1 + src/CleanApiStarter.AspNetCore/Extensions.cs | 119 +++++------- .../GlobalUsings.cs | 9 + .../IEndpointGroup.cs | 10 + .../OpenTelemetryDefaults.cs | 41 +++++ .../ProblemDetailsExceptionHandler.cs | 54 ++++++ .../WebApplicationExtensions.cs | 39 ++++ .../AppSettings.cs | 8 + .../CleanApiStarter.Configuration.csproj | 15 ++ .../ConnectionStringSettings.cs | 7 + .../GlobalUsings.cs | 5 + .../OptionsRegistrationExtensions.cs | 19 ++ .../CleanApiStarter.Infrastructure.csproj | 1 + .../Database/DatabaseConnectionFactory.cs | 14 +- .../DependencyInjection.cs | 26 +-- .../GlobalUsings.cs | 28 +-- .../Repositories/WordRepository.cs | 12 +- 32 files changed, 553 insertions(+), 283 deletions(-) rename src/CleanApiStarter.Api/Endpoints/{WordEndpoints.cs => V1/Words.cs} (68%) create mode 100644 src/CleanApiStarter.Api/Endpoints/V2/Words.cs create mode 100644 src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs create mode 100644 src/CleanApiStarter.AspNetCore/IEndpointGroup.cs create mode 100644 src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs create mode 100644 src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs create mode 100644 src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs create mode 100644 src/CleanApiStarter.Configuration/AppSettings.cs create mode 100644 src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj create mode 100644 src/CleanApiStarter.Configuration/ConnectionStringSettings.cs create mode 100644 src/CleanApiStarter.Configuration/GlobalUsings.cs create mode 100644 src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs diff --git a/AGENTS.md b/AGENTS.md index 8573ebd..fe52dad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Keep project names fully qualified: - `CleanApiStarter.Api` - `CleanApiStarter.Application` + - `CleanApiStarter.Configuration` - `CleanApiStarter.Domain` - `CleanApiStarter.Infrastructure` - `CleanApiStarter.AppHost` @@ -20,6 +21,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Keep clean architecture application layers under the `/src/` solution folder: - API - Application + - Configuration - Domain - Infrastructure - Keep Aspire/runtime support projects under the `/src/Common/` solution folder in `CleanApiStarter.slnx`: @@ -33,8 +35,9 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Dependencies point inward: - `Domain` references nothing. - `Application` references `Domain`. - - `Infrastructure` references `Application`. - - `Api` composes `Application`, `Infrastructure`, and `AspNetCore`. + - `Configuration` references no application layers and contains only plain options classes plus options registration helpers. + - `Infrastructure` references `Application` and `Configuration`. + - `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. - Keep repository interfaces in `Application`, not `Domain`. - Keep database implementation details in `Infrastructure`. - Do not move `IDatabaseConnectionFactory` into `Application` or `Domain`. @@ -59,7 +62,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Use a single Postgres database: `postgres`. - Do not create or reference a separate `wordlibrary` database. -- AppHost should expose the API connection as `ConnectionStrings__postgres`. +- AppHost should expose the API connection from the `postgres` resource, and application settings should read it through `ConnectionStrings:Postgres`. - `docker-compose.yml` should use `POSTGRES_DB=postgres`. - Database schema scripts live in `database/migrations`, for example: - `database/migrations/V001__create_words_table.sql` @@ -76,9 +79,12 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Keep `CleanApiStarter.AspNetCore`. - It centralizes Aspire-friendly runtime defaults: - OpenTelemetry traces, metrics, logs, and OTLP export + - problem-details exception handling + - `/version` endpoint - health endpoints - service discovery - default HTTP client resilience + - security defaults such as removing the Kestrel `Server` response header - API `Program.cs` should stay small and call: - `builder.AddAspNetCoreDefaults();` - `app.UseAspNetCoreDefaults();` @@ -86,11 +92,20 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Shared middleware such as HTTP request logging belongs in `CleanApiStarter.AspNetCore`, not duplicated inside each API project. - Keep OpenTelemetry logs configured to include scopes, formatted messages, and parsed state values so structured message-template properties show up in Aspire. - Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{WordId}` and `{WordCount}`. +- Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly when services need configuration values. +- Do not add generic options registration helpers until the template has multiple real options sections that need them. +- Do not create a broad `Shared` project. Keep cross-project settings in `CleanApiStarter.Configuration`. +- Keep dependency injection validation enabled with `ValidateOnBuild` and `ValidateScopes`. ## API Style - Prefer Minimal APIs for this template. -- Keep `Program.cs` small by placing route groups in endpoint mapping classes such as `Api/Endpoints/WordEndpoints.cs`. +- Keep `Program.cs` small by placing route groups in endpoint group classes under version folders such as `Api/Endpoints/V1/Words.cs`. +- Endpoint group classes should implement `IEndpointGroup` from `CleanApiStarter.AspNetCore` and be mapped through `app.MapEndpoints(Assembly.GetExecutingAssembly());`. +- Use built-in Minimal API mapping methods with explicit `.WithName(...)`; do not add custom `MapGet`/`MapPost` overloads that shadow framework methods. +- API versions are selected with the required `X-Api-Version` request header. +- Endpoint groups should declare `MajorVersion` to match their folder, for example `V1` uses `1` and `V2` uses `2`. +- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetAllWordsV1` and `GetAllWordsV2`. - Do not reintroduce MVC controllers unless the template intentionally changes direction. ## Packages diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx index 9b35a61..60e10b1 100644 --- a/CleanApiStarter.slnx +++ b/CleanApiStarter.slnx @@ -21,6 +21,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 2437245..2db7419 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,20 +8,24 @@ 10.0.0 - + + + + + @@ -38,4 +42,4 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index 4c890cc..5214914 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ A Clean Architecture API starter template for .NET, Aspire, PostgreSQL, and OpenTelemetry. -The API uses Minimal APIs with endpoint mapping classes so `Program.cs` stays small while routes remain easy to find. +The API uses Minimal APIs with endpoint group classes. `CleanApiStarter.AspNetCore` discovers endpoint groups from the API assembly so `Program.cs` stays small while routes remain easy to find. + +API versions are selected with the required `X-Api-Version` request header. Endpoint groups live under version folders such as `Endpoints/V1` and `Endpoints/V2`. ## Learn OpenTelemetry with .NET Aspire @@ -21,7 +23,7 @@ Open the Aspire dashboard URL printed in the terminal. The AppHost starts: - `postgres`: the API database - `pgAdmin`: a browser UI for inspecting PostgreSQL -The API receives its connection string from Aspire as `ConnectionStrings__postgres`. Running the API directly still uses `ConnectionStrings:DefaultConnection` from `src/CleanApiStarter.Api/appsettings.Development.json`, which matches the existing `docker-compose.yml`. +The API receives its `postgres` connection string from Aspire, which binds to `ConnectionStrings:Postgres`. Running the API directly uses `ConnectionStrings:Postgres` from `src/CleanApiStarter.Api/appsettings.Development.json`, which matches the existing `docker-compose.yml`. Database scripts live in `database`. Aspire and Docker Compose copy `database/migrations` into the Postgres container init folder. Docker runs them only when the Postgres data directory is created for the first time. Because the AppHost uses the persistent `clean-api-starter-postgres-data` volume, delete that Docker volume if you need to replay the scripts from scratch. @@ -35,6 +37,7 @@ After the dashboard is running, send requests to the API through Scalar or anoth - Request logs show method, path, response status code, and duration without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`. - Traces show incoming HTTP requests and PostgreSQL commands from Npgsql. - Metrics show ASP.NET Core, HTTP client, and .NET runtime measurements. +- `/version` exposes the running application version. - Health checks expose `/health` and `/alive` in Development. ### Coding Conventions @@ -43,6 +46,8 @@ Cancellation tokens are explicit at application and infrastructure boundaries. D Use structured logging message templates instead of interpolated log strings. Prefer stable property names such as `{WordId}` or `{WordCount}` so Aspire and OpenTelemetry can index and filter them. +Configuration classes live in `CleanApiStarter.Configuration`. Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly where configuration values are needed. + ### Run with Docker Compose Only If you want to run PostgreSQL without Aspire: diff --git a/src/CleanApiStarter.Api/Api.http b/src/CleanApiStarter.Api/Api.http index f420003..2bef6bf 100644 --- a/src/CleanApiStarter.Api/Api.http +++ b/src/CleanApiStarter.Api/Api.http @@ -1,12 +1,14 @@ -@CleanApiStarter_Api_HostAddress = http://localhost:5219 +@CleanApiStarter_Api_HostAddress = https://localhost:7285 GET {{CleanApiStarter_Api_HostAddress}}/api/words Accept: application/json +X-Api-Version: 1.0 ### POST {{CleanApiStarter_Api_HostAddress}}/api/words Content-Type: application/json +X-Api-Version: 1.0 { "text": "observable", @@ -16,3 +18,9 @@ Content-Type: application/json } ### + +GET {{CleanApiStarter_Api_HostAddress}}/api/words +Accept: application/json +X-Api-Version: 2.0 + +### diff --git a/src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs b/src/CleanApiStarter.Api/Endpoints/V1/Words.cs similarity index 68% rename from src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs rename to src/CleanApiStarter.Api/Endpoints/V1/Words.cs index e5fdfb7..9aeb17c 100644 --- a/src/CleanApiStarter.Api/Endpoints/WordEndpoints.cs +++ b/src/CleanApiStarter.Api/Endpoints/V1/Words.cs @@ -1,28 +1,27 @@ -namespace CleanApiStarter.Api.Endpoints; +namespace CleanApiStarter.Api.Endpoints.V1; -public static class WordEndpoints +public sealed class Words : IEndpointGroup { - public static IEndpointRouteBuilder MapWordEndpoints(this IEndpointRouteBuilder endpoints) - { - RouteGroupBuilder group = endpoints.MapGroup("/api/words") - .WithTags("Words"); + public static int MajorVersion => 1; - group.MapPost("/", CreateWord) - .WithName(nameof(CreateWord)); + public static string RoutePrefix => "/api/words"; - group.MapGet("/{id:guid}", GetWord) - .WithName(nameof(GetWord)); + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.MapPost("/", CreateWord) + .WithName("CreateWordV1"); - group.MapGet("/", GetAllWords) - .WithName(nameof(GetAllWords)); + groupBuilder.MapGet("/", GetAllWords) + .WithName("GetAllWordsV1"); - group.MapPut("/{id:guid}", UpdateWord) - .WithName(nameof(UpdateWord)); + groupBuilder.MapGet("/{id:guid}", GetWord) + .WithName("GetWordV1"); - group.MapDelete("/{id:guid}", DeleteWord) - .WithName(nameof(DeleteWord)); + groupBuilder.MapPut("/{id:guid}", UpdateWord) + .WithName("UpdateWordV1"); - return endpoints; + groupBuilder.MapDelete("/{id:guid}", DeleteWord) + .WithName("DeleteWordV1"); } private static async Task CreateWord( diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Words.cs b/src/CleanApiStarter.Api/Endpoints/V2/Words.cs new file mode 100644 index 0000000..c869ab6 --- /dev/null +++ b/src/CleanApiStarter.Api/Endpoints/V2/Words.cs @@ -0,0 +1,27 @@ +namespace CleanApiStarter.Api.Endpoints.V2; + +public sealed class Words : IEndpointGroup +{ + public static int MajorVersion => 2; + + public static string RoutePrefix => "/api/words"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.MapGet("/", GetAllWords) + .WithName("GetAllWordsV2"); + } + + private static async Task GetAllWords( + IWordService wordService, + CancellationToken cancellationToken) + { + IEnumerable words = await wordService.GetAllWordsAsync(cancellationToken); + + return Results.Ok(new + { + ApiVersion = "2.0", + Items = words + }); + } +} diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs index 30dfefd..01ef137 100644 --- a/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -1,10 +1,12 @@ // Global using directives -global using CleanApiStarter.Api.Endpoints; +global using System.Reflection; + global using CleanApiStarter.Application; global using CleanApiStarter.Application.Interfaces; global using CleanApiStarter.Application.Models; global using CleanApiStarter.AspNetCore; +global using CleanApiStarter.Configuration; global using CleanApiStarter.Infrastructure; global using Microsoft.AspNetCore.Http; diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs index 1f4ec8e..05392a3 100644 --- a/src/CleanApiStarter.Api/Program.cs +++ b/src/CleanApiStarter.Api/Program.cs @@ -1,12 +1,13 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.AddAspNetCoreDefaults(); - +builder.AddAspNetCoreDefaults(); + builder.Services.AddEndpointsApiExplorer(); builder.Services.AddOpenApi(); +builder.Services.AddAppSettings(builder.Configuration); builder.Services.AddApplication(); -builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddInfrastructure(); WebApplication app = builder.Build(); @@ -15,11 +16,11 @@ { app.MapOpenApi(); app.MapScalarApiReference(); -} - -app.UseAspNetCoreDefaults(); -app.UseHttpsRedirection(); -app.MapWordEndpoints(); -app.MapDefaultEndpoints(); - +} + +app.UseAspNetCoreDefaults(); +app.UseHttpsRedirection(); +app.MapEndpoints(Assembly.GetExecutingAssembly()); +app.MapDefaultEndpoints(); + app.Run(); diff --git a/src/CleanApiStarter.Api/appsettings.Development.json b/src/CleanApiStarter.Api/appsettings.Development.json index 7de2b6c..1690903 100644 --- a/src/CleanApiStarter.Api/appsettings.Development.json +++ b/src/CleanApiStarter.Api/appsettings.Development.json @@ -1,11 +1,5 @@ { "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } + "Postgres": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" } } diff --git a/src/CleanApiStarter.AppHost/Program.cs b/src/CleanApiStarter.AppHost/Program.cs index 823eb18..aa0a117 100644 --- a/src/CleanApiStarter.AppHost/Program.cs +++ b/src/CleanApiStarter.AppHost/Program.cs @@ -1,19 +1,19 @@ IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); -string databasePath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "database")); -string databaseMigrationsPath = Path.Combine(databasePath, "migrations"); - -IResourceBuilder postgresServer = builder.AddPostgres("postgres-server") - .WithImageTag("latest") - .WithVolume("clean-api-starter-postgres-data", "/var/lib/postgresql") - .WithInitFiles(databaseMigrationsPath) - .WithPgAdmin(); - -IResourceBuilder database = postgresServer.AddDatabase("postgres") - .WithCreationScript("SELECT 1;"); - -builder.AddProject("api") - .WithReference(database) - .WaitFor(database); +string databasePath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "database")); +string databaseMigrationsPath = Path.Combine(databasePath, "migrations"); -builder.Build().Run(); +IResourceBuilder postgresServer = builder.AddPostgres("postgres-server") + .WithImageTag("latest") + .WithVolume("clean-api-starter-postgres-data", "/var/lib/postgresql") + .WithInitFiles(databaseMigrationsPath) + .WithPgAdmin(); + +IResourceBuilder database = postgresServer.AddDatabase("postgres") + .WithCreationScript("SELECT 1;"); + +builder.AddProject("api") + .WithReference(database) + .WaitFor(database); + +builder.Build().Run(); \ No newline at end of file diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/src/CleanApiStarter.Application/GlobalUsings.cs index f169a6b..2e2a517 100644 --- a/src/CleanApiStarter.Application/GlobalUsings.cs +++ b/src/CleanApiStarter.Application/GlobalUsings.cs @@ -1,7 +1,7 @@ global using CleanApiStarter.Application.Interfaces; global using CleanApiStarter.Application.Models; -global using CleanApiStarter.Application.Services; -global using CleanApiStarter.Domain.Entities; - -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Logging; +global using CleanApiStarter.Application.Services; +global using CleanApiStarter.Domain.Entities; + +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Interfaces/IWordService.cs b/src/CleanApiStarter.Application/Interfaces/IWordService.cs index 46f0c59..4ff4a63 100644 --- a/src/CleanApiStarter.Application/Interfaces/IWordService.cs +++ b/src/CleanApiStarter.Application/Interfaces/IWordService.cs @@ -1,10 +1,10 @@ namespace CleanApiStarter.Application.Interfaces; -public interface IWordService -{ - Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken); - Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); - Task> GetAllWordsAsync(CancellationToken cancellationToken); - Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken); - Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); -} +public interface IWordService +{ + Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken); + Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); + Task> GetAllWordsAsync(CancellationToken cancellationToken); + Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken); + Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Services/WordService.cs b/src/CleanApiStarter.Application/Services/WordService.cs index ffa6d09..9f1b7fb 100644 --- a/src/CleanApiStarter.Application/Services/WordService.cs +++ b/src/CleanApiStarter.Application/Services/WordService.cs @@ -1,95 +1,95 @@ -namespace CleanApiStarter.Application.Services; - -public class WordService(IWordRepository wordRepository, ILogger logger) : IWordService -{ - public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken) - { - Word word = new() - { +namespace CleanApiStarter.Application.Services; + +public class WordService(IWordRepository wordRepository, ILogger logger) : IWordService +{ + public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken) + { + Word word = new() + { Id = Guid.NewGuid(), Text = wordDto.Text, Meaning = wordDto.Meaning, Synonyms = wordDto.Synonyms, UsageExample = wordDto.UsageExample, - CreatedAt = DateTime.UtcNow - }; - - logger.LogInformation( - "Creating word {WordId} with {SynonymCount} synonyms", - word.Id, - word.Synonyms.Count); - - Guid wordId = await wordRepository.AddWordAsync(word, cancellationToken); - - logger.LogInformation("Created word {WordId}", wordId); - - return wordId; - } - - public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken) - { - Word? word = await wordRepository.GetWordByIdAsync(id, cancellationToken); - - if (word == null) - { - logger.LogWarning("Word {WordId} was not found", id); - return null; - } - - logger.LogInformation("Retrieved word {WordId}", id); - - return MapToDto(word); - } - - public async Task> GetAllWordsAsync(CancellationToken cancellationToken) - { - IEnumerable words = await wordRepository.GetAllWordsAsync(cancellationToken); - Word[] wordList = words.ToArray(); - - logger.LogInformation("Retrieved {WordCount} words", wordList.Length); - - return wordList.Select(MapToDto); - } - - public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken) - { - Word? existingWord = await wordRepository.GetWordByIdAsync(id, cancellationToken); - if (existingWord == null) - { - logger.LogWarning("Word {WordId} could not be updated because it was not found", id); - return false; - } - - existingWord.Text = wordDto.Text; - existingWord.Meaning = wordDto.Meaning; + CreatedAt = DateTime.UtcNow + }; + + logger.LogInformation( + "Creating word {WordId} with {SynonymCount} synonyms", + word.Id, + word.Synonyms.Count); + + Guid wordId = await wordRepository.AddWordAsync(word, cancellationToken); + + logger.LogInformation("Created word {WordId}", wordId); + + return wordId; + } + + public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken) + { + Word? word = await wordRepository.GetWordByIdAsync(id, cancellationToken); + + if (word == null) + { + logger.LogWarning("Word {WordId} was not found", id); + return null; + } + + logger.LogInformation("Retrieved word {WordId}", id); + + return MapToDto(word); + } + + public async Task> GetAllWordsAsync(CancellationToken cancellationToken) + { + IEnumerable words = await wordRepository.GetAllWordsAsync(cancellationToken); + Word[] wordList = words.ToArray(); + + logger.LogInformation("Retrieved {WordCount} words", wordList.Length); + + return wordList.Select(MapToDto); + } + + public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken) + { + Word? existingWord = await wordRepository.GetWordByIdAsync(id, cancellationToken); + if (existingWord == null) + { + logger.LogWarning("Word {WordId} could not be updated because it was not found", id); + return false; + } + + existingWord.Text = wordDto.Text; + existingWord.Meaning = wordDto.Meaning; existingWord.Synonyms = wordDto.Synonyms; - existingWord.UsageExample = wordDto.UsageExample; - existingWord.UpdatedAt = DateTime.UtcNow; - - bool updated = await wordRepository.UpdateWordAsync(existingWord, cancellationToken); - - logger.LogInformation( - "Updated word {WordId}; success: {UpdateSucceeded}", - id, - updated); - - return updated; - } - - public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken) - { - bool deleted = await wordRepository.DeleteWordAsync(id, cancellationToken); - - if (!deleted) - { - logger.LogWarning("Word {WordId} could not be deleted because it was not found", id); - return false; - } - - logger.LogInformation("Deleted word {WordId}", id); - - return true; - } + existingWord.UsageExample = wordDto.UsageExample; + existingWord.UpdatedAt = DateTime.UtcNow; + + bool updated = await wordRepository.UpdateWordAsync(existingWord, cancellationToken); + + logger.LogInformation( + "Updated word {WordId}; success: {UpdateSucceeded}", + id, + updated); + + return updated; + } + + public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken) + { + bool deleted = await wordRepository.DeleteWordAsync(id, cancellationToken); + + if (!deleted) + { + logger.LogWarning("Word {WordId} could not be deleted because it was not found", id); + return false; + } + + logger.LogInformation("Deleted word {WordId}", id); + + return true; + } private static WordDto MapToDto(Word word) { @@ -102,4 +102,4 @@ private static WordDto MapToDto(Word word) UsageExample = word.UsageExample }; } -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs new file mode 100644 index 0000000..645a49b --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs @@ -0,0 +1,52 @@ +namespace CleanApiStarter.AspNetCore; + +public static partial class Extensions +{ + private static void AddSecurityDefaults(this IHostApplicationBuilder builder) + { + builder.Services.Configure( + options => options.AddServerHeader = false); + } + + private static void AddProblemDetailsDefaults(this IHostApplicationBuilder builder) + { + builder.Services.AddExceptionHandler(); + builder.Services.AddProblemDetails(); + } + + private static void AddApiVersioningDefaults(this IHostApplicationBuilder builder) + { + builder.Services.AddApiVersioning(options => + { + options.DefaultApiVersion = new ApiVersion(1); + options.AssumeDefaultVersionWhenUnspecified = false; + options.ReportApiVersions = true; + options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version"); + }); + } + + private static void AddHttpLoggingDefaults(this IHostApplicationBuilder builder) + { + builder.Logging.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information); + + builder.Services.AddHttpLogging(options => + { + options.LoggingFields = + HttpLoggingFields.RequestMethod | + HttpLoggingFields.RequestPath | + HttpLoggingFields.ResponseStatusCode | + HttpLoggingFields.Duration; + }); + } + + private static void AddServiceDiscoveryDefaults(this IHostApplicationBuilder builder) + { + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + http.AddServiceDiscovery(); + }); + } +} diff --git a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj index 73f91c4..9247916 100644 --- a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj +++ b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj @@ -7,6 +7,7 @@ + diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/src/CleanApiStarter.AspNetCore/Extensions.cs index 89738df..38f29f1 100644 --- a/src/CleanApiStarter.AspNetCore/Extensions.cs +++ b/src/CleanApiStarter.AspNetCore/Extensions.cs @@ -1,37 +1,30 @@ namespace CleanApiStarter.AspNetCore; - -public static class Extensions -{ - public static IHostApplicationBuilder AddAspNetCoreDefaults(this IHostApplicationBuilder builder) - { - builder.ConfigureOpenTelemetry(); - builder.Logging.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information); - builder.Services.AddHttpLogging(options => +public static partial class Extensions +{ + public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBuilder builder) + { + builder.Host.UseDefaultServiceProvider((_, options) => { - options.LoggingFields = - HttpLoggingFields.RequestMethod | - HttpLoggingFields.RequestPath | - HttpLoggingFields.ResponseStatusCode | - HttpLoggingFields.Duration; + options.ValidateOnBuild = true; + options.ValidateScopes = true; }); - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - http.AddStandardResilienceHandler(); - http.AddServiceDiscovery(); - }); - - builder.Services.AddHealthChecks() - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - + builder.ConfigureOpenTelemetry(); + builder.AddSecurityDefaults(); + builder.AddProblemDetailsDefaults(); + builder.AddApiVersioningDefaults(); + builder.AddHttpLoggingDefaults(); + builder.AddServiceDiscoveryDefaults(); + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + return builder; } public static WebApplication UseAspNetCoreDefaults(this WebApplication app) { + app.UseExceptionHandler(); app.UseHttpLogging(); return app; @@ -39,59 +32,31 @@ public static WebApplication UseAspNetCoreDefaults(this WebApplication app) public static WebApplication MapDefaultEndpoints(this WebApplication app) { + app.MapGet("/version", () => + { + Assembly assembly = Assembly.GetEntryAssembly() ?? typeof(Extensions).Assembly; + AssemblyInformationalVersionAttribute? informationalVersion = assembly + .GetCustomAttribute(); + + return Results.Ok(new + { + Application = app.Environment.ApplicationName, + Version = assembly.GetName().Version?.ToString(), + informationalVersion?.InformationalVersion + }); + }) + .WithName("GetVersion") + .WithTags("Version"); + if (app.Environment.IsDevelopment()) - { - app.MapHealthChecks("/health"); - app.MapHealthChecks("/alive", new HealthCheckOptions - { - Predicate = registration => registration.Tags.Contains("live") - }); - } - - return app; - } - - private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) - { - builder.Logging.AddOpenTelemetry(logging => { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - logging.ParseStateValues = true; - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddNpgsqlInstrumentation() - .AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddNpgsql(); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder) - { - bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.Configure(logging => logging.AddOtlpExporter()); - builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); - builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); - } - - return builder; - } + app.MapHealthChecks("/health"); + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("live") + }); + } + + return app; + } } diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs index c47d19a..b863a3a 100644 --- a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs +++ b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs @@ -1,6 +1,15 @@ +global using System.Reflection; + +global using Asp.Versioning; +global using Asp.Versioning.Builder; + global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Diagnostics; global using Microsoft.AspNetCore.Diagnostics.HealthChecks; +global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.HttpLogging; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.Routing; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Diagnostics.HealthChecks; global using Microsoft.Extensions.Hosting; diff --git a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs b/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs new file mode 100644 index 0000000..66a2135 --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs @@ -0,0 +1,10 @@ +namespace CleanApiStarter.AspNetCore; + +public interface IEndpointGroup +{ + static virtual int MajorVersion => 1; + + static virtual string? RoutePrefix => null; + + static abstract void Map(RouteGroupBuilder groupBuilder); +} diff --git a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs b/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs new file mode 100644 index 0000000..70733a2 --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs @@ -0,0 +1,41 @@ +namespace CleanApiStarter.AspNetCore; + +public static partial class Extensions +{ + private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + logging.ParseStateValues = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddNpgsqlInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddNpgsql(); + }); + + bool useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.Configure(logging => logging.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); + builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); + } + + return builder; + } +} diff --git a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs new file mode 100644 index 0000000..0c2320c --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs @@ -0,0 +1,54 @@ +namespace CleanApiStarter.AspNetCore; + +public sealed class ProblemDetailsExceptionHandler : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + (int statusCode, ProblemDetails problemDetails) = exception switch + { + BadHttpRequestException badRequestException => ( + StatusCodes.Status400BadRequest, + new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Bad Request", + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", + Detail = badRequestException.Message + }), + ArgumentException argumentException => ( + StatusCodes.Status400BadRequest, + new ProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Title = "Bad Request", + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", + Detail = argumentException.Message + }), + UnauthorizedAccessException => ( + StatusCodes.Status401Unauthorized, + new ProblemDetails + { + Status = StatusCodes.Status401Unauthorized, + Title = "Unauthorized", + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.2" + }), + _ => ( + StatusCodes.Status500InternalServerError, + new ProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Title = "Internal Server Error", + Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1", + Detail = "An unexpected error occurred." + }) + }; + + httpContext.Response.StatusCode = statusCode; + await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); + + return true; + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs new file mode 100644 index 0000000..59f4337 --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs @@ -0,0 +1,39 @@ +namespace CleanApiStarter.AspNetCore; + +public static class WebApplicationExtensions +{ + public static WebApplication MapEndpoints(this WebApplication app, Assembly assembly) + { + IEnumerable endpointGroupTypes = assembly.GetExportedTypes() + .Where(type => type is { IsAbstract: false, IsInterface: false } + && type.IsAssignableTo(typeof(IEndpointGroup))); + + foreach (Type type in endpointGroupTypes) + { + ApiVersion apiVersion = GetApiVersion(type); + string groupName = type.Name; + string routePrefix = type.GetProperty(nameof(IEndpointGroup.RoutePrefix))?.GetValue(null) as string + ?? $"/api/{groupName}"; + ApiVersionSet versionSet = app.NewApiVersionSet() + .HasApiVersion(apiVersion) + .ReportApiVersions() + .Build(); + + RouteGroupBuilder group = app.MapGroup(routePrefix) + .WithTags(groupName) + .WithApiVersionSet(versionSet) + .MapToApiVersion(apiVersion); + + type.GetMethod(nameof(IEndpointGroup.Map))!.Invoke(null, [group]); + } + + return app; + } + + private static ApiVersion GetApiVersion(Type endpointGroupType) + { + int majorVersion = endpointGroupType.GetProperty(nameof(IEndpointGroup.MajorVersion))?.GetValue(null) as int? ?? 1; + + return new ApiVersion(majorVersion); + } +} diff --git a/src/CleanApiStarter.Configuration/AppSettings.cs b/src/CleanApiStarter.Configuration/AppSettings.cs new file mode 100644 index 0000000..fd74519 --- /dev/null +++ b/src/CleanApiStarter.Configuration/AppSettings.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Configuration; + +public sealed class AppSettings +{ + [Required] + [ValidateObjectMembers] + public ConnectionStringSettings ConnectionStrings { get; set; } = new(); +} diff --git a/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj b/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj new file mode 100644 index 0000000..002e9d2 --- /dev/null +++ b/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj @@ -0,0 +1,15 @@ + + + + CleanApiStarter.Configuration + CleanApiStarter.Configuration + + + + + + + + + + diff --git a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs b/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs new file mode 100644 index 0000000..1299016 --- /dev/null +++ b/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs @@ -0,0 +1,7 @@ +namespace CleanApiStarter.Configuration; + +public sealed class ConnectionStringSettings +{ + [Required] + public string Postgres { get; set; } = string.Empty; +} diff --git a/src/CleanApiStarter.Configuration/GlobalUsings.cs b/src/CleanApiStarter.Configuration/GlobalUsings.cs new file mode 100644 index 0000000..a2250c9 --- /dev/null +++ b/src/CleanApiStarter.Configuration/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using System.ComponentModel.DataAnnotations; + +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Options; diff --git a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs b/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs new file mode 100644 index 0000000..6b767c7 --- /dev/null +++ b/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs @@ -0,0 +1,19 @@ +namespace CleanApiStarter.Configuration; + +public static class OptionsRegistrationExtensions +{ + public static IServiceCollection AddAppSettings( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService>().Value); + + return services; + } +} diff --git a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj index 772f2b1..a814aa1 100644 --- a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj +++ b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj @@ -7,6 +7,7 @@ + diff --git a/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs b/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs index c406b04..b82d596 100644 --- a/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs +++ b/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs @@ -5,10 +5,10 @@ public interface IDatabaseConnectionFactory IDbConnection CreateConnection(); } -public class PostgresConnectionFactory(string connectionString) : IDatabaseConnectionFactory -{ - public IDbConnection CreateConnection() - { - return new NpgsqlConnection(connectionString); - } -} \ No newline at end of file +public class PostgresConnectionFactory(AppSettings appSettings) : IDatabaseConnectionFactory +{ + public IDbConnection CreateConnection() + { + return new NpgsqlConnection(appSettings.ConnectionStrings.Postgres); + } +} diff --git a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs index a1b6aa1..b2a5e19 100644 --- a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs +++ b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs @@ -1,24 +1,12 @@ namespace CleanApiStarter.Infrastructure; -public static class DependencyInjection -{ - public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) - { - // Register database connection factory - string? connectionString = - configuration.GetConnectionString("postgres") ?? - configuration.GetConnectionString("DefaultConnection"); +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped(); - if (string.IsNullOrWhiteSpace(connectionString)) - { - throw new InvalidOperationException("Database connection string 'postgres' or 'DefaultConnection' is missing."); - } - - services.AddSingleton(_ => new PostgresConnectionFactory(connectionString!)); - - // Register repositories - services.AddScoped(); - - return services; + return services; } } diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs index 3b36d17..0106a08 100644 --- a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs +++ b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs @@ -1,14 +1,14 @@ -global using System.Data; -global using System.Text.Json; - -global using CleanApiStarter.Application.Interfaces; -global using CleanApiStarter.Domain.Entities; -global using CleanApiStarter.Infrastructure.Database; -global using CleanApiStarter.Infrastructure.Repositories; - -global using Dapper; - -global using Microsoft.Extensions.Configuration; -global using Microsoft.Extensions.DependencyInjection; - -global using Npgsql; \ No newline at end of file +global using System.Data; +global using System.Text.Json; + +global using CleanApiStarter.Application.Interfaces; +global using CleanApiStarter.Configuration; +global using CleanApiStarter.Domain.Entities; +global using CleanApiStarter.Infrastructure.Database; +global using CleanApiStarter.Infrastructure.Repositories; + +global using Dapper; + +global using Microsoft.Extensions.DependencyInjection; + +global using Npgsql; diff --git a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs index d255b5e..ce6b6cf 100644 --- a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs +++ b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs @@ -2,7 +2,7 @@ namespace CleanApiStarter.Infrastructure.Repositories; public class WordRepository(IDatabaseConnectionFactory connectionFactory) : IWordRepository { - public async Task AddWordAsync(Word word, CancellationToken cancellationToken) + public async Task AddWordAsync(Word word, CancellationToken cancellationToken) { const string sql = """ @@ -28,7 +28,7 @@ RETURNING id return id; } - public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken) + public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken) { const string sql = """ @@ -43,7 +43,7 @@ FROM words return word?.ToDomain(); } - public async Task> GetAllWordsAsync(CancellationToken cancellationToken) + public async Task> GetAllWordsAsync(CancellationToken cancellationToken) { const string sql = """ @@ -57,7 +57,7 @@ FROM words return words.Select(word => word.ToDomain()); } - public async Task UpdateWordAsync(Word word, CancellationToken cancellationToken) + public async Task UpdateWordAsync(Word word, CancellationToken cancellationToken) { const string sql = """ @@ -85,7 +85,7 @@ UPDATE words return rowsAffected > 0; } - public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken) + public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken) { const string sql = "DELETE FROM words WHERE id = @Id"; @@ -118,4 +118,4 @@ public Word ToDomain() }; } } -} +} \ No newline at end of file From 937cacf052d0d2e2676634ac35e03bdcac01bb70 Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:03:22 +0800 Subject: [PATCH 05/33] implement authentication with Google ID tokens and JWT, refactor data access to use EF Core --- AGENTS.md | 24 +- CleanApiStarter.slnx | 1 + Directory.Packages.props | 6 +- README.md | 28 ++- .../V002__create_identity_tables.sql | 91 ++++++++ src/CleanApiStarter.Api/Api.http | 25 ++- .../CleanApiStarter.Api.csproj | 1 + .../Endpoints/GoogleLoginPage.cs | 65 ++++++ src/CleanApiStarter.Api/Endpoints/V1/Auth.cs | 39 ++++ src/CleanApiStarter.Api/Endpoints/V1/Words.cs | 4 +- src/CleanApiStarter.Api/Endpoints/V2/Words.cs | 4 +- src/CleanApiStarter.Api/GlobalUsings.cs | 11 +- src/CleanApiStarter.Api/Program.cs | 16 +- .../Services/CurrentUser.cs | 9 + .../appsettings.Development.json | 11 + .../Common/Interfaces/IUser.cs | 7 + .../Features/Auth/AuthTokenDto.cs | 8 + .../Features/Auth/CurrentUserDto.cs | 12 + .../Features/Auth/GoogleSignInDto.cs | 6 + .../Features/Auth/IAuthService.cs | 8 + .../Words}/IWordRepository.cs | 18 +- .../Words}/IWordService.cs | 18 +- .../{Models => Features/Words}/WordDto.cs | 34 +-- .../Words}/WordService.cs | 208 +++++++++--------- .../GlobalUsings.cs | 6 +- .../AspNetCoreDefaultServices.cs | 33 ++- .../CleanApiStarter.AspNetCore.csproj | 5 + src/CleanApiStarter.AspNetCore/Extensions.cs | 5 +- .../GlobalUsings.cs | 42 ++-- .../IEndpointGroup.cs | 2 +- .../OpenTelemetryDefaults.cs | 2 +- .../ProblemDetailsExceptionHandler.cs | 20 +- .../UserIdHttpLoggingInterceptor.cs | 28 +++ .../WebApplicationExtensions.cs | 2 +- .../AppSettings.cs | 6 +- .../AuthenticationSettings.cs | 12 + .../ConnectionStringSettings.cs | 2 +- .../GlobalUsings.cs | 2 +- .../GoogleAuthenticationSettings.cs | 7 + .../JwtAuthenticationSettings.cs | 17 ++ .../OptionsRegistrationExtensions.cs | 2 +- .../CleanApiStarter.Infrastructure.csproj | 6 +- .../Database/DatabaseConnectionFactory.cs | 14 -- .../DependencyInjection.cs | 28 ++- .../GlobalUsings.cs | 19 +- .../Identity/ApplicationUser.cs | 3 + .../Identity/GoogleAuthService.cs | 151 +++++++++++++ .../Persistence/ApplicationDbContext.cs | 14 ++ .../Configuration/WordConfiguration.cs | 40 ++++ .../Repositories/WordRepository.cs | 109 ++------- .../Controllers/WordsControllerTests.cs | 131 ----------- .../Repositories/WordRepositoryTests.cs | 61 ----- .../Services/WordServiceTests.cs | 139 ------------ 53 files changed, 917 insertions(+), 645 deletions(-) create mode 100644 database/migrations/V002__create_identity_tables.sql create mode 100644 src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs create mode 100644 src/CleanApiStarter.Api/Endpoints/V1/Auth.cs create mode 100644 src/CleanApiStarter.Api/Services/CurrentUser.cs create mode 100644 src/CleanApiStarter.Application/Common/Interfaces/IUser.cs create mode 100644 src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs create mode 100644 src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs create mode 100644 src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs create mode 100644 src/CleanApiStarter.Application/Features/Auth/IAuthService.cs rename src/CleanApiStarter.Application/{Interfaces => Features/Words}/IWordRepository.cs (88%) rename src/CleanApiStarter.Application/{Interfaces => Features/Words}/IWordService.cs (88%) rename src/CleanApiStarter.Application/{Models => Features/Words}/WordDto.cs (89%) rename src/CleanApiStarter.Application/{Services => Features/Words}/WordService.cs (95%) create mode 100644 src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs create mode 100644 src/CleanApiStarter.Configuration/AuthenticationSettings.cs create mode 100644 src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs create mode 100644 src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs delete mode 100644 src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs create mode 100644 src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs create mode 100644 src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs create mode 100644 src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs create mode 100644 src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs delete mode 100644 tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs delete mode 100644 tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs delete mode 100644 tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs diff --git a/AGENTS.md b/AGENTS.md index fe52dad..edbeebc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,12 +40,17 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. - Keep repository interfaces in `Application`, not `Domain`. - Keep database implementation details in `Infrastructure`. -- Do not move `IDatabaseConnectionFactory` into `Application` or `Domain`. - Keep domain models persistence-agnostic. For example, `Word.Synonyms` stays `List`, even though Postgres stores it as `jsonb`. -- Private database projection types such as `WordRow` may live inside their repository when they are only used there. +- Use EF Core through `ApplicationDbContext` for application persistence. Do not reintroduce Dapper for the default template data access path. +- Keep `ApplicationDbContext` in `CleanApiStarter.Infrastructure/Persistence`, not in `Identity`. The context owns both application entities and Identity storage, so it is a persistence concern. +- Keep EF Core fluent API entity maps in `CleanApiStarter.Infrastructure/Persistence/Configuration` using `IEntityTypeConfiguration`. Do not put entity mapping logic directly inside `ApplicationDbContext` unless there is a very small one-off reason. +- Keep Identity-specific classes, such as `ApplicationUser` and auth services, under `CleanApiStarter.Infrastructure/Identity`. ## API and Application Conventions +- Organize the `Application` project by feature. Put feature-specific contracts, DTOs, and application services under folders such as `Application/Features/Words` or `Application/Features/Auth`. +- Do not create generic `Application/Services` or `Application/Models` folders for feature-specific code. +- Keep cross-cutting application abstractions under `Application/Common`, for example `Application/Common/Interfaces`. - Use Scalar, not Swagger/Swashbuckle. - Use `Microsoft.AspNetCore.OpenApi` with: - `builder.Services.AddOpenApi();` @@ -67,6 +72,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Database schema scripts live in `database/migrations`, for example: - `database/migrations/V001__create_words_table.sql` - Do not add API startup database initialization such as `DbInitializer` or DbUp calls. Aspire/Docker init scripts own local schema creation. +- EF Core is used for application data access and Identity storage, but schema creation still belongs to the SQL scripts in `database/migrations`. - Docker Postgres init scripts run only on first volume creation. If scripts need to replay, delete the old volume. - This repo uses `postgres:latest`. Because Postgres 18+ expects the data volume mounted at `/var/lib/postgresql`, do not mount the volume at `/var/lib/postgresql/data`. - In Aspire, use a server resource name that does not conflict with the database resource name, for example: @@ -103,11 +109,23 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Keep `Program.cs` small by placing route groups in endpoint group classes under version folders such as `Api/Endpoints/V1/Words.cs`. - Endpoint group classes should implement `IEndpointGroup` from `CleanApiStarter.AspNetCore` and be mapped through `app.MapEndpoints(Assembly.GetExecutingAssembly());`. - Use built-in Minimal API mapping methods with explicit `.WithName(...)`; do not add custom `MapGet`/`MapPost` overloads that shadow framework methods. -- API versions are selected with the required `X-Api-Version` request header. +- API versions are selected with the optional `X-Api-Version` request header. Missing versions default to v1. - Endpoint groups should declare `MajorVersion` to match their folder, for example `V1` uses `1` and `V2` uses `2`. - Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetAllWordsV1` and `GetAllWordsV2`. - Do not reintroduce MVC controllers unless the template intentionally changes direction. +## Authentication + +- Authentication is API-first: + - clients obtain a Google ID token + - `POST /api/auth/google` validates it + - the API issues its own JWT +- Keep JWT bearer authentication setup in `CleanApiStarter.AspNetCore`. +- Keep local user/role storage in ASP.NET Core Identity under `CleanApiStarter.Infrastructure`. +- Do not use cookies as the default API auth mechanism. +- Keep the development Google login helper page unversioned so it can be opened directly in a browser. +- Protected API calls should send `Authorization: Bearer `. Send `X-Api-Version` only when selecting a non-default API version. + ## Packages - Manage versions centrally in `Directory.Packages.props`. diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx index 60e10b1..7fa4829 100644 --- a/CleanApiStarter.slnx +++ b/CleanApiStarter.slnx @@ -11,6 +11,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 2db7419..601f3d5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,8 +14,10 @@ - + + + @@ -30,6 +32,7 @@ + @@ -39,6 +42,7 @@ + diff --git a/README.md b/README.md index 5214914..5a2c448 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,31 @@ A Clean Architecture API starter template for .NET, Aspire, PostgreSQL, and Open The API uses Minimal APIs with endpoint group classes. `CleanApiStarter.AspNetCore` discovers endpoint groups from the API assembly so `Program.cs` stays small while routes remain easy to find. -API versions are selected with the required `X-Api-Version` request header. Endpoint groups live under version folders such as `Endpoints/V1` and `Endpoints/V2`. +API versions are selected with the optional `X-Api-Version` request header. Requests without a version use v1 by default. Endpoint groups live under version folders such as `Endpoints/V1` and `Endpoints/V2`. + +## Authentication + +The API validates Google ID tokens and issues its own JWT access token. Google proves the user identity; ASP.NET Core Identity stores local users, roles, and external logins. + +Set the Google client id with user-secrets: + +```bash +dotnet user-secrets set "Authentication:Google:ClientId" "" --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +``` + +For local testing, open the development-only helper page: + +```text +https://localhost:7285/auth/google-login +``` + +After signing in with Google, the page calls `POST /api/auth/google` and shows the API JWT. Use that JWT with protected endpoints: + +```http +Authorization: Bearer +``` + +New Google users are created as local Identity users and assigned the `User` role by default. The database init scripts create the `User` and `Admin` roles. ## Learn OpenTelemetry with .NET Aspire @@ -34,7 +58,7 @@ Database scripts live in `database`. Aspire and Docker Compose copy `database/mi After the dashboard is running, send requests to the API through Scalar or another HTTP client. In the Aspire dashboard: - Logs show structured application and framework log entries. The word use cases emit fields such as `WordId`, `SynonymCount`, `WordCount`, and `UpdateSucceeded`. -- Request logs show method, path, response status code, and duration without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`. +- Request logs show method, path, response status code, duration, and `UserId` without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`. - Traces show incoming HTTP requests and PostgreSQL commands from Npgsql. - Metrics show ASP.NET Core, HTTP client, and .NET runtime measurements. - `/version` exposes the running application version. diff --git a/database/migrations/V002__create_identity_tables.sql b/database/migrations/V002__create_identity_tables.sql new file mode 100644 index 0000000..43c1f17 --- /dev/null +++ b/database/migrations/V002__create_identity_tables.sql @@ -0,0 +1,91 @@ +CREATE TABLE IF NOT EXISTS "AspNetRoles" ( + "Id" TEXT PRIMARY KEY, + "Name" VARCHAR(256) NULL, + "NormalizedName" VARCHAR(256) NULL, + "ConcurrencyStamp" TEXT NULL +); + +CREATE TABLE IF NOT EXISTS "AspNetUsers" ( + "Id" TEXT PRIMARY KEY, + "UserName" VARCHAR(256) NULL, + "NormalizedUserName" VARCHAR(256) NULL, + "Email" VARCHAR(256) NULL, + "NormalizedEmail" VARCHAR(256) NULL, + "EmailConfirmed" BOOLEAN NOT NULL, + "PasswordHash" TEXT NULL, + "SecurityStamp" TEXT NULL, + "ConcurrencyStamp" TEXT NULL, + "PhoneNumber" TEXT NULL, + "PhoneNumberConfirmed" BOOLEAN NOT NULL, + "TwoFactorEnabled" BOOLEAN NOT NULL, + "LockoutEnd" TIMESTAMP WITH TIME ZONE NULL, + "LockoutEnabled" BOOLEAN NOT NULL, + "AccessFailedCount" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" ( + "Id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "RoleId" TEXT NOT NULL, + "ClaimType" TEXT NULL, + "ClaimValue" TEXT NULL, + CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId" + FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS "AspNetUserClaims" ( + "Id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "UserId" TEXT NOT NULL, + "ClaimType" TEXT NULL, + "ClaimValue" TEXT NULL, + CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS "AspNetUserLogins" ( + "LoginProvider" TEXT NOT NULL, + "ProviderKey" TEXT NOT NULL, + "ProviderDisplayName" TEXT NULL, + "UserId" TEXT NOT NULL, + CONSTRAINT "PK_AspNetUserLogins" + PRIMARY KEY ("LoginProvider", "ProviderKey"), + CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS "AspNetUserRoles" ( + "UserId" TEXT NOT NULL, + "RoleId" TEXT NOT NULL, + CONSTRAINT "PK_AspNetUserRoles" + PRIMARY KEY ("UserId", "RoleId"), + CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" + FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( + "UserId" TEXT NOT NULL, + "LoginProvider" TEXT NOT NULL, + "Name" TEXT NOT NULL, + "Value" TEXT NULL, + CONSTRAINT "PK_AspNetUserTokens" + PRIMARY KEY ("UserId", "LoginProvider", "Name"), + CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" + FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail"); +CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName"); +CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName"); +CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId"); +CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId"); +CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId"); +CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId"); + +INSERT INTO "AspNetRoles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp") +VALUES ('user', 'User', 'USER', gen_random_uuid()::text) +ON CONFLICT ("Id") DO NOTHING; + +INSERT INTO "AspNetRoles" ("Id", "Name", "NormalizedName", "ConcurrencyStamp") +VALUES ('admin', 'Admin', 'ADMIN', gen_random_uuid()::text) +ON CONFLICT ("Id") DO NOTHING; diff --git a/src/CleanApiStarter.Api/Api.http b/src/CleanApiStarter.Api/Api.http index 2bef6bf..7deb1e4 100644 --- a/src/CleanApiStarter.Api/Api.http +++ b/src/CleanApiStarter.Api/Api.http @@ -1,14 +1,29 @@ @CleanApiStarter_Api_HostAddress = https://localhost:7285 +@AccessToken = paste-api-jwt-here GET {{CleanApiStarter_Api_HostAddress}}/api/words Accept: application/json -X-Api-Version: 1.0 + +### + +POST {{CleanApiStarter_Api_HostAddress}}/api/auth/google +Content-Type: application/json + +{ + "idToken": "paste-google-id-token-here" +} + +### + +GET {{CleanApiStarter_Api_HostAddress}}/api/auth/me +Accept: application/json +Authorization: Bearer {{AccessToken}} ### POST {{CleanApiStarter_Api_HostAddress}}/api/words Content-Type: application/json -X-Api-Version: 1.0 +Authorization: Bearer {{AccessToken}} { "text": "observable", @@ -22,5 +37,11 @@ X-Api-Version: 1.0 GET {{CleanApiStarter_Api_HostAddress}}/api/words Accept: application/json X-Api-Version: 2.0 +Authorization: Bearer {{AccessToken}} + +### + +DELETE {{CleanApiStarter_Api_HostAddress}}/api/words/00000000-0000-0000-0000-000000000000 +Authorization: Bearer {{AccessToken}} ### diff --git a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj index 6014433..47c56e1 100644 --- a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +++ b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj @@ -3,6 +3,7 @@ CleanApiStarter.Api CleanApiStarter.Api + clean-api-starter diff --git a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs new file mode 100644 index 0000000..e57f2ec --- /dev/null +++ b/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs @@ -0,0 +1,65 @@ +namespace CleanApiStarter.Api.Endpoints; + +public static class GoogleLoginPage +{ + public static WebApplication MapGoogleLoginPage(this WebApplication app) + { + if (!app.Environment.IsDevelopment()) + { + return app; + } + + app.MapGet("/auth/google-login", (AppSettings appSettings) => + { + string clientId = HtmlEncoder.Default.Encode(appSettings.Authentication.Google.ClientId); + + string html = + $$""" + + + + + + Google Login + + + + +

Google Login

+
+
+ +

API JWT

+
Sign in with Google to generate a token.
+ + + + + """; + + return Results.Content(html, "text/html"); + }) + .AllowAnonymous() + .WithName("GoogleLoginPage"); + + return app; + } +} diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs b/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs new file mode 100644 index 0000000..13fa398 --- /dev/null +++ b/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs @@ -0,0 +1,39 @@ +namespace CleanApiStarter.Api.Endpoints.V1; + +public sealed class Auth : IEndpointGroup +{ + public static int MajorVersion => 1; + + public static string RoutePrefix => "/api/auth"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.MapPost("/google", SignInWithGoogle) + .AllowAnonymous() + .WithName("SignInWithGoogleV1"); + + groupBuilder.MapGet("/me", GetCurrentUser) + .RequireAuthorization() + .WithName("GetCurrentUserV1"); + } + + private static async Task SignInWithGoogle( + GoogleSignInDto signInDto, + IAuthService authService, + CancellationToken cancellationToken) + { + AuthTokenDto token = await authService.SignInWithGoogleAsync(signInDto, cancellationToken); + + return Results.Ok(token); + } + + private static async Task GetCurrentUser( + ClaimsPrincipal principal, + IAuthService authService, + CancellationToken cancellationToken) + { + CurrentUserDto currentUser = await authService.GetCurrentUserAsync(principal, cancellationToken); + + return Results.Ok(currentUser); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Words.cs b/src/CleanApiStarter.Api/Endpoints/V1/Words.cs index 9aeb17c..0b8c011 100644 --- a/src/CleanApiStarter.Api/Endpoints/V1/Words.cs +++ b/src/CleanApiStarter.Api/Endpoints/V1/Words.cs @@ -8,6 +8,8 @@ public sealed class Words : IEndpointGroup public static void Map(RouteGroupBuilder groupBuilder) { + groupBuilder.RequireAuthorization(); + groupBuilder.MapPost("/", CreateWord) .WithName("CreateWordV1"); @@ -73,4 +75,4 @@ private static async Task DeleteWord( return success ? Results.NoContent() : Results.NotFound(); } -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Words.cs b/src/CleanApiStarter.Api/Endpoints/V2/Words.cs index c869ab6..27bd502 100644 --- a/src/CleanApiStarter.Api/Endpoints/V2/Words.cs +++ b/src/CleanApiStarter.Api/Endpoints/V2/Words.cs @@ -8,6 +8,8 @@ public sealed class Words : IEndpointGroup public static void Map(RouteGroupBuilder groupBuilder) { + groupBuilder.RequireAuthorization(); + groupBuilder.MapGet("/", GetAllWords) .WithName("GetAllWordsV2"); } @@ -24,4 +26,4 @@ private static async Task GetAllWords( Items = words }); } -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs index 01ef137..43234fb 100644 --- a/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -1,10 +1,15 @@ // Global using directives global using System.Reflection; +global using System.Security.Claims; +global using System.Text.Encodings.Web; +global using CleanApiStarter.Api.Endpoints; +global using CleanApiStarter.Api.Services; global using CleanApiStarter.Application; -global using CleanApiStarter.Application.Interfaces; -global using CleanApiStarter.Application.Models; +global using CleanApiStarter.Application.Common.Interfaces; +global using CleanApiStarter.Application.Features.Auth; +global using CleanApiStarter.Application.Features.Words; global using CleanApiStarter.AspNetCore; global using CleanApiStarter.Configuration; global using CleanApiStarter.Infrastructure; @@ -12,4 +17,4 @@ global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Routing; -global using Scalar.AspNetCore; +global using Scalar.AspNetCore; \ No newline at end of file diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs index 05392a3..d599159 100644 --- a/src/CleanApiStarter.Api/Program.cs +++ b/src/CleanApiStarter.Api/Program.cs @@ -2,12 +2,13 @@ builder.AddAspNetCoreDefaults(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddOpenApi(); - -builder.Services.AddAppSettings(builder.Configuration); -builder.Services.AddApplication(); -builder.Services.AddInfrastructure(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddOpenApi(); + +builder.Services.AddAppSettings(builder.Configuration); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(); +builder.Services.AddScoped(); WebApplication app = builder.Build(); @@ -20,7 +21,8 @@ app.UseAspNetCoreDefaults(); app.UseHttpsRedirection(); +app.MapGoogleLoginPage(); app.MapEndpoints(Assembly.GetExecutingAssembly()); app.MapDefaultEndpoints(); -app.Run(); +app.Run(); \ No newline at end of file diff --git a/src/CleanApiStarter.Api/Services/CurrentUser.cs b/src/CleanApiStarter.Api/Services/CurrentUser.cs new file mode 100644 index 0000000..c48f384 --- /dev/null +++ b/src/CleanApiStarter.Api/Services/CurrentUser.cs @@ -0,0 +1,9 @@ +namespace CleanApiStarter.Api.Services; + +public class CurrentUser(IHttpContextAccessor httpContextAccessor) : IUser +{ + public string? Id => httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier); + + public List? Roles => + httpContextAccessor.HttpContext?.User.FindAll(ClaimTypes.Role).Select(x => x.Value).ToList(); +} \ No newline at end of file diff --git a/src/CleanApiStarter.Api/appsettings.Development.json b/src/CleanApiStarter.Api/appsettings.Development.json index 1690903..68309f2 100644 --- a/src/CleanApiStarter.Api/appsettings.Development.json +++ b/src/CleanApiStarter.Api/appsettings.Development.json @@ -1,5 +1,16 @@ { "ConnectionStrings": { "Postgres": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" + }, + "Authentication": { + "Google": { + "ClientId": "" + }, + "Jwt": { + "Issuer": "CleanApiStarter", + "Audience": "CleanApiStarter.Api", + "SigningKey": "development-only-signing-key-change-me", + "ExpirationMinutes": 60 + } } } diff --git a/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs b/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs new file mode 100644 index 0000000..00c53cc --- /dev/null +++ b/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs @@ -0,0 +1,7 @@ +namespace CleanApiStarter.Application.Common.Interfaces; + +public interface IUser +{ + string? Id { get; } + List? Roles { get; } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs b/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs new file mode 100644 index 0000000..7f32c89 --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Application.Features.Auth; + +public sealed class AuthTokenDto +{ + public string AccessToken { get; init; } = string.Empty; + + public DateTimeOffset ExpiresAt { get; init; } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs b/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs new file mode 100644 index 0000000..f2618c7 --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs @@ -0,0 +1,12 @@ +namespace CleanApiStarter.Application.Features.Auth; + +public sealed class CurrentUserDto +{ + public string UserId { get; init; } = string.Empty; + + public string Email { get; init; } = string.Empty; + + public string Name { get; init; } = string.Empty; + + public IReadOnlyCollection Roles { get; init; } = []; +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs new file mode 100644 index 0000000..3eb4663 --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs @@ -0,0 +1,6 @@ +namespace CleanApiStarter.Application.Features.Auth; + +public sealed class GoogleSignInDto +{ + public string IdToken { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs b/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs new file mode 100644 index 0000000..27bcb46 --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Application.Features.Auth; + +public interface IAuthService +{ + Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken); + + Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Interfaces/IWordRepository.cs b/src/CleanApiStarter.Application/Features/Words/IWordRepository.cs similarity index 88% rename from src/CleanApiStarter.Application/Interfaces/IWordRepository.cs rename to src/CleanApiStarter.Application/Features/Words/IWordRepository.cs index c38a9c3..75aac8c 100644 --- a/src/CleanApiStarter.Application/Interfaces/IWordRepository.cs +++ b/src/CleanApiStarter.Application/Features/Words/IWordRepository.cs @@ -1,10 +1,10 @@ -namespace CleanApiStarter.Application.Interfaces; - -public interface IWordRepository -{ - Task AddWordAsync(Word word, CancellationToken cancellationToken); - Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); - Task> GetAllWordsAsync(CancellationToken cancellationToken); - Task UpdateWordAsync(Word word, CancellationToken cancellationToken); - Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); +namespace CleanApiStarter.Application.Features.Words; + +public interface IWordRepository +{ + Task AddWordAsync(Word word, CancellationToken cancellationToken); + Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); + Task> GetAllWordsAsync(CancellationToken cancellationToken); + Task UpdateWordAsync(Word word, CancellationToken cancellationToken); + Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Interfaces/IWordService.cs b/src/CleanApiStarter.Application/Features/Words/IWordService.cs similarity index 88% rename from src/CleanApiStarter.Application/Interfaces/IWordService.cs rename to src/CleanApiStarter.Application/Features/Words/IWordService.cs index 4ff4a63..8797d87 100644 --- a/src/CleanApiStarter.Application/Interfaces/IWordService.cs +++ b/src/CleanApiStarter.Application/Features/Words/IWordService.cs @@ -1,10 +1,10 @@ -namespace CleanApiStarter.Application.Interfaces; - -public interface IWordService -{ - Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken); - Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); - Task> GetAllWordsAsync(CancellationToken cancellationToken); - Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken); - Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); +namespace CleanApiStarter.Application.Features.Words; + +public interface IWordService +{ + Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken); + Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); + Task> GetAllWordsAsync(CancellationToken cancellationToken); + Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken); + Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Models/WordDto.cs b/src/CleanApiStarter.Application/Features/Words/WordDto.cs similarity index 89% rename from src/CleanApiStarter.Application/Models/WordDto.cs rename to src/CleanApiStarter.Application/Features/Words/WordDto.cs index 645bf2c..ef18d55 100644 --- a/src/CleanApiStarter.Application/Models/WordDto.cs +++ b/src/CleanApiStarter.Application/Features/Words/WordDto.cs @@ -1,18 +1,18 @@ -namespace CleanApiStarter.Application.Models; - -public class WordDto -{ - public Guid Id { get; set; } - public string Text { get; set; } = string.Empty; - public string Meaning { get; set; } = string.Empty; - public List Synonyms { get; set; } = new(); - public string UsageExample { get; set; } = string.Empty; -} - -public class CreateWordDto -{ - public string Text { get; set; } = string.Empty; - public string Meaning { get; set; } = string.Empty; - public List Synonyms { get; set; } = new(); - public string UsageExample { get; set; } = string.Empty; +namespace CleanApiStarter.Application.Features.Words; + +public class WordDto +{ + public Guid Id { get; set; } + public string Text { get; set; } = string.Empty; + public string Meaning { get; set; } = string.Empty; + public List Synonyms { get; set; } = new(); + public string UsageExample { get; set; } = string.Empty; +} + +public class CreateWordDto +{ + public string Text { get; set; } = string.Empty; + public string Meaning { get; set; } = string.Empty; + public List Synonyms { get; set; } = new(); + public string UsageExample { get; set; } = string.Empty; } \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Services/WordService.cs b/src/CleanApiStarter.Application/Features/Words/WordService.cs similarity index 95% rename from src/CleanApiStarter.Application/Services/WordService.cs rename to src/CleanApiStarter.Application/Features/Words/WordService.cs index 9f1b7fb..ceb05cd 100644 --- a/src/CleanApiStarter.Application/Services/WordService.cs +++ b/src/CleanApiStarter.Application/Features/Words/WordService.cs @@ -1,105 +1,105 @@ -namespace CleanApiStarter.Application.Services; - -public class WordService(IWordRepository wordRepository, ILogger logger) : IWordService -{ - public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken) - { - Word word = new() - { - Id = Guid.NewGuid(), - Text = wordDto.Text, - Meaning = wordDto.Meaning, - Synonyms = wordDto.Synonyms, - UsageExample = wordDto.UsageExample, - CreatedAt = DateTime.UtcNow - }; - - logger.LogInformation( - "Creating word {WordId} with {SynonymCount} synonyms", - word.Id, - word.Synonyms.Count); - - Guid wordId = await wordRepository.AddWordAsync(word, cancellationToken); - - logger.LogInformation("Created word {WordId}", wordId); - - return wordId; - } - - public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken) - { - Word? word = await wordRepository.GetWordByIdAsync(id, cancellationToken); - - if (word == null) - { - logger.LogWarning("Word {WordId} was not found", id); - return null; - } - - logger.LogInformation("Retrieved word {WordId}", id); - - return MapToDto(word); - } - - public async Task> GetAllWordsAsync(CancellationToken cancellationToken) - { - IEnumerable words = await wordRepository.GetAllWordsAsync(cancellationToken); - Word[] wordList = words.ToArray(); - - logger.LogInformation("Retrieved {WordCount} words", wordList.Length); - - return wordList.Select(MapToDto); - } - - public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken) - { - Word? existingWord = await wordRepository.GetWordByIdAsync(id, cancellationToken); - if (existingWord == null) - { - logger.LogWarning("Word {WordId} could not be updated because it was not found", id); - return false; - } - - existingWord.Text = wordDto.Text; - existingWord.Meaning = wordDto.Meaning; - existingWord.Synonyms = wordDto.Synonyms; - existingWord.UsageExample = wordDto.UsageExample; - existingWord.UpdatedAt = DateTime.UtcNow; - - bool updated = await wordRepository.UpdateWordAsync(existingWord, cancellationToken); - - logger.LogInformation( - "Updated word {WordId}; success: {UpdateSucceeded}", - id, - updated); - - return updated; - } - - public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken) - { - bool deleted = await wordRepository.DeleteWordAsync(id, cancellationToken); - - if (!deleted) - { - logger.LogWarning("Word {WordId} could not be deleted because it was not found", id); - return false; - } - - logger.LogInformation("Deleted word {WordId}", id); - - return true; - } - - private static WordDto MapToDto(Word word) - { - return new WordDto - { - Id = word.Id, - Text = word.Text, - Meaning = word.Meaning, - Synonyms = word.Synonyms, - UsageExample = word.UsageExample - }; - } +namespace CleanApiStarter.Application.Features.Words; + +public class WordService(IWordRepository wordRepository, ILogger logger) : IWordService +{ + public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken) + { + Word word = new() + { + Id = Guid.NewGuid(), + Text = wordDto.Text, + Meaning = wordDto.Meaning, + Synonyms = wordDto.Synonyms, + UsageExample = wordDto.UsageExample, + CreatedAt = DateTime.UtcNow + }; + + logger.LogInformation( + "Creating word {WordId} with {SynonymCount} synonyms", + word.Id, + word.Synonyms.Count); + + Guid wordId = await wordRepository.AddWordAsync(word, cancellationToken); + + logger.LogInformation("Created word {WordId}", wordId); + + return wordId; + } + + public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken) + { + Word? word = await wordRepository.GetWordByIdAsync(id, cancellationToken); + + if (word == null) + { + logger.LogWarning("Word {WordId} was not found", id); + return null; + } + + logger.LogInformation("Retrieved word {WordId}", id); + + return MapToDto(word); + } + + public async Task> GetAllWordsAsync(CancellationToken cancellationToken) + { + IEnumerable words = await wordRepository.GetAllWordsAsync(cancellationToken); + Word[] wordList = words.ToArray(); + + logger.LogInformation("Retrieved {WordCount} words", wordList.Length); + + return wordList.Select(MapToDto); + } + + public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken) + { + Word? existingWord = await wordRepository.GetWordByIdAsync(id, cancellationToken); + if (existingWord == null) + { + logger.LogWarning("Word {WordId} could not be updated because it was not found", id); + return false; + } + + existingWord.Text = wordDto.Text; + existingWord.Meaning = wordDto.Meaning; + existingWord.Synonyms = wordDto.Synonyms; + existingWord.UsageExample = wordDto.UsageExample; + existingWord.UpdatedAt = DateTime.UtcNow; + + bool updated = await wordRepository.UpdateWordAsync(existingWord, cancellationToken); + + logger.LogInformation( + "Updated word {WordId}; success: {UpdateSucceeded}", + id, + updated); + + return updated; + } + + public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken) + { + bool deleted = await wordRepository.DeleteWordAsync(id, cancellationToken); + + if (!deleted) + { + logger.LogWarning("Word {WordId} could not be deleted because it was not found", id); + return false; + } + + logger.LogInformation("Deleted word {WordId}", id); + + return true; + } + + private static WordDto MapToDto(Word word) + { + return new WordDto + { + Id = word.Id, + Text = word.Text, + Meaning = word.Meaning, + Synonyms = word.Synonyms, + UsageExample = word.UsageExample + }; + } } \ No newline at end of file diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/src/CleanApiStarter.Application/GlobalUsings.cs index 2e2a517..b92085b 100644 --- a/src/CleanApiStarter.Application/GlobalUsings.cs +++ b/src/CleanApiStarter.Application/GlobalUsings.cs @@ -1,6 +1,6 @@ -global using CleanApiStarter.Application.Interfaces; -global using CleanApiStarter.Application.Models; -global using CleanApiStarter.Application.Services; +global using System.Security.Claims; + +global using CleanApiStarter.Application.Features.Words; global using CleanApiStarter.Domain.Entities; global using Microsoft.Extensions.DependencyInjection; diff --git a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs index 645a49b..101b619 100644 --- a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs +++ b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs @@ -6,6 +6,8 @@ private static void AddSecurityDefaults(this IHostApplicationBuilder builder) { builder.Services.Configure( options => options.AddServerHeader = false); + + builder.Services.AddHttpContextAccessor(); } private static void AddProblemDetailsDefaults(this IHostApplicationBuilder builder) @@ -19,24 +21,53 @@ private static void AddApiVersioningDefaults(this IHostApplicationBuilder builde builder.Services.AddApiVersioning(options => { options.DefaultApiVersion = new ApiVersion(1); - options.AssumeDefaultVersionWhenUnspecified = false; + options.AssumeDefaultVersionWhenUnspecified = true; options.ReportApiVersions = true; options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version"); }); } + private static void AddAuthenticationDefaults(this WebApplicationBuilder builder) + { + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(); + + builder.Services.AddAuthorization(); + + builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure((options, appSettings) => + { + JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt; + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtSettings.Issuer, + ValidateAudience = true, + ValidAudience = jwtSettings.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SigningKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1) + }; + }); + } + private static void AddHttpLoggingDefaults(this IHostApplicationBuilder builder) { builder.Logging.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information); builder.Services.AddHttpLogging(options => { + options.CombineLogs = true; options.LoggingFields = HttpLoggingFields.RequestMethod | HttpLoggingFields.RequestPath | HttpLoggingFields.ResponseStatusCode | HttpLoggingFields.Duration; }); + + builder.Services.AddHttpLoggingInterceptor(); } private static void AddServiceDiscoveryDefaults(this IHostApplicationBuilder builder) diff --git a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj index 9247916..cf25978 100644 --- a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj +++ b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj @@ -5,9 +5,14 @@ CleanApiStarter.AspNetCore + + + + + diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/src/CleanApiStarter.AspNetCore/Extensions.cs index 38f29f1..dd2b540 100644 --- a/src/CleanApiStarter.AspNetCore/Extensions.cs +++ b/src/CleanApiStarter.AspNetCore/Extensions.cs @@ -14,6 +14,7 @@ public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBui builder.AddSecurityDefaults(); builder.AddProblemDetailsDefaults(); builder.AddApiVersioningDefaults(); + builder.AddAuthenticationDefaults(); builder.AddHttpLoggingDefaults(); builder.AddServiceDiscoveryDefaults(); builder.Services.AddHealthChecks() @@ -25,7 +26,9 @@ public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBui public static WebApplication UseAspNetCoreDefaults(this WebApplication app) { app.UseExceptionHandler(); + app.UseAuthentication(); app.UseHttpLogging(); + app.UseAuthorization(); return app; } @@ -59,4 +62,4 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) return app; } -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs index b863a3a..d12fd40 100644 --- a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs +++ b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs @@ -1,23 +1,29 @@ -global using System.Reflection; - -global using Asp.Versioning; -global using Asp.Versioning.Builder; - -global using Microsoft.AspNetCore.Builder; -global using Microsoft.AspNetCore.Diagnostics; -global using Microsoft.AspNetCore.Diagnostics.HealthChecks; -global using Microsoft.AspNetCore.Http; -global using Microsoft.AspNetCore.HttpLogging; -global using Microsoft.AspNetCore.Mvc; -global using Microsoft.AspNetCore.Routing; -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Diagnostics.HealthChecks; -global using Microsoft.Extensions.Hosting; -global using Microsoft.Extensions.Logging; +global using System.IdentityModel.Tokens.Jwt; +global using System.Reflection; +global using System.Security.Claims; +global using System.Text; + +global using Asp.Versioning; +global using Asp.Versioning.Builder; + +global using CleanApiStarter.Configuration; + +global using Microsoft.AspNetCore.Authentication.JwtBearer; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Diagnostics; +global using Microsoft.AspNetCore.Diagnostics.HealthChecks; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.HttpLogging; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.Routing; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Diagnostics.HealthChecks; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Microsoft.IdentityModel.Tokens; global using Npgsql; -global using OpenTelemetry; global using OpenTelemetry.Logs; global using OpenTelemetry.Metrics; -global using OpenTelemetry.Trace; +global using OpenTelemetry.Trace; \ No newline at end of file diff --git a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs b/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs index 66a2135..cc4f2e9 100644 --- a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs +++ b/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs @@ -7,4 +7,4 @@ public interface IEndpointGroup static virtual string? RoutePrefix => null; static abstract void Map(RouteGroupBuilder groupBuilder); -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs b/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs index 70733a2..154ef68 100644 --- a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs +++ b/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs @@ -38,4 +38,4 @@ private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicat return builder; } -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs index 0c2320c..4f270a9 100644 --- a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs +++ b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs @@ -1,6 +1,6 @@ namespace CleanApiStarter.AspNetCore; -public sealed class ProblemDetailsExceptionHandler : IExceptionHandler +public sealed class ProblemDetailsExceptionHandler(ILogger logger) : IExceptionHandler { public async ValueTask TryHandleAsync( HttpContext httpContext, @@ -46,6 +46,24 @@ public async ValueTask TryHandleAsync( }) }; + if (statusCode >= StatusCodes.Status500InternalServerError) + { + logger.LogError( + exception, + "Unhandled exception while processing {RequestMethod} {RequestPath}", + httpContext.Request.Method, + httpContext.Request.Path); + } + else + { + logger.LogWarning( + exception, + "Handled exception with status {StatusCode} while processing {RequestMethod} {RequestPath}", + statusCode, + httpContext.Request.Method, + httpContext.Request.Path); + } + httpContext.Response.StatusCode = statusCode; await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); diff --git a/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs b/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs new file mode 100644 index 0000000..4e8fff7 --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs @@ -0,0 +1,28 @@ +namespace CleanApiStarter.AspNetCore; + +public sealed class UserIdHttpLoggingInterceptor : IHttpLoggingInterceptor +{ + private const string UserIdParameterName = "UserId"; + private const string AnonymousUserId = "anonymous"; + + public ValueTask OnRequestAsync(HttpLoggingInterceptorContext logContext) + { + logContext.AddParameter(UserIdParameterName, GetUserId(logContext.HttpContext)); + + return ValueTask.CompletedTask; + } + + public ValueTask OnResponseAsync(HttpLoggingInterceptorContext logContext) + { + logContext.AddParameter(UserIdParameterName, GetUserId(logContext.HttpContext)); + + return ValueTask.CompletedTask; + } + + private static string GetUserId(HttpContext httpContext) + { + return httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? AnonymousUserId; + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs index 59f4337..023c50b 100644 --- a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs +++ b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs @@ -36,4 +36,4 @@ private static ApiVersion GetApiVersion(Type endpointGroupType) return new ApiVersion(majorVersion); } -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.Configuration/AppSettings.cs b/src/CleanApiStarter.Configuration/AppSettings.cs index fd74519..a0f32ea 100644 --- a/src/CleanApiStarter.Configuration/AppSettings.cs +++ b/src/CleanApiStarter.Configuration/AppSettings.cs @@ -5,4 +5,8 @@ public sealed class AppSettings [Required] [ValidateObjectMembers] public ConnectionStringSettings ConnectionStrings { get; set; } = new(); -} + + [Required] + [ValidateObjectMembers] + public AuthenticationSettings Authentication { get; set; } = new(); +} \ No newline at end of file diff --git a/src/CleanApiStarter.Configuration/AuthenticationSettings.cs b/src/CleanApiStarter.Configuration/AuthenticationSettings.cs new file mode 100644 index 0000000..ff38cd4 --- /dev/null +++ b/src/CleanApiStarter.Configuration/AuthenticationSettings.cs @@ -0,0 +1,12 @@ +namespace CleanApiStarter.Configuration; + +public sealed class AuthenticationSettings +{ + [Required] + [ValidateObjectMembers] + public GoogleAuthenticationSettings Google { get; set; } = new(); + + [Required] + [ValidateObjectMembers] + public JwtAuthenticationSettings Jwt { get; set; } = new(); +} \ No newline at end of file diff --git a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs b/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs index 1299016..deb6fbe 100644 --- a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs +++ b/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs @@ -4,4 +4,4 @@ public sealed class ConnectionStringSettings { [Required] public string Postgres { get; set; } = string.Empty; -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.Configuration/GlobalUsings.cs b/src/CleanApiStarter.Configuration/GlobalUsings.cs index a2250c9..1fd17fc 100644 --- a/src/CleanApiStarter.Configuration/GlobalUsings.cs +++ b/src/CleanApiStarter.Configuration/GlobalUsings.cs @@ -2,4 +2,4 @@ global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Options; +global using Microsoft.Extensions.Options; \ No newline at end of file diff --git a/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs b/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs new file mode 100644 index 0000000..1fda7db --- /dev/null +++ b/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs @@ -0,0 +1,7 @@ +namespace CleanApiStarter.Configuration; + +public sealed class GoogleAuthenticationSettings +{ + [Required] + public string ClientId { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs b/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs new file mode 100644 index 0000000..9183996 --- /dev/null +++ b/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs @@ -0,0 +1,17 @@ +namespace CleanApiStarter.Configuration; + +public sealed class JwtAuthenticationSettings +{ + [Required] + public string Issuer { get; set; } = string.Empty; + + [Required] + public string Audience { get; set; } = string.Empty; + + [Required] + [MinLength(32)] + public string SigningKey { get; set; } = string.Empty; + + [Range(1, 1440)] + public int ExpirationMinutes { get; set; } = 60; +} \ No newline at end of file diff --git a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs b/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs index 6b767c7..051f517 100644 --- a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs +++ b/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs @@ -16,4 +16,4 @@ public static IServiceCollection AddAppSettings( return services; } -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj index a814aa1..71d039f 100644 --- a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj +++ b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj @@ -11,8 +11,10 @@ - - + + + + diff --git a/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs b/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs deleted file mode 100644 index b82d596..0000000 --- a/src/CleanApiStarter.Infrastructure/Database/DatabaseConnectionFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Database; - -public interface IDatabaseConnectionFactory -{ - IDbConnection CreateConnection(); -} - -public class PostgresConnectionFactory(AppSettings appSettings) : IDatabaseConnectionFactory -{ - public IDbConnection CreateConnection() - { - return new NpgsqlConnection(appSettings.ConnectionStrings.Postgres); - } -} diff --git a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs index b2a5e19..72cd87e 100644 --- a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs +++ b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs @@ -1,12 +1,22 @@ namespace CleanApiStarter.Infrastructure; -public static class DependencyInjection -{ - public static IServiceCollection AddInfrastructure(this IServiceCollection services) - { - services.AddSingleton(); - services.AddScoped(); - - return services; +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services) + { + services.AddDbContext((serviceProvider, options) => + { + AppSettings appSettings = serviceProvider.GetRequiredService(); + options.UseNpgsql(appSettings.ConnectionStrings.Postgres); + }); + + services.AddIdentityCore() + .AddRoles() + .AddEntityFrameworkStores(); + + services.AddScoped(); + services.AddScoped(); + + return services; } -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs index 0106a08..8e0a629 100644 --- a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs +++ b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs @@ -1,14 +1,21 @@ -global using System.Data; +global using System.IdentityModel.Tokens.Jwt; +global using System.Security.Claims; +global using System.Text; global using System.Text.Json; -global using CleanApiStarter.Application.Interfaces; +global using CleanApiStarter.Application.Features.Auth; +global using CleanApiStarter.Application.Features.Words; global using CleanApiStarter.Configuration; global using CleanApiStarter.Domain.Entities; -global using CleanApiStarter.Infrastructure.Database; +global using CleanApiStarter.Infrastructure.Identity; +global using CleanApiStarter.Infrastructure.Persistence; global using CleanApiStarter.Infrastructure.Repositories; -global using Dapper; +global using Google.Apis.Auth; +global using Microsoft.AspNetCore.Identity; +global using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.Metadata.Builders; global using Microsoft.Extensions.DependencyInjection; - -global using Npgsql; +global using Microsoft.IdentityModel.Tokens; \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs b/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..2996a8e --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,3 @@ +namespace CleanApiStarter.Infrastructure.Identity; + +public sealed class ApplicationUser : IdentityUser; \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs b/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs new file mode 100644 index 0000000..d6e61ce --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs @@ -0,0 +1,151 @@ +namespace CleanApiStarter.Infrastructure.Identity; + +public sealed class GoogleAuthService( + AppSettings appSettings, + UserManager userManager, + RoleManager roleManager) : IAuthService +{ + private const string GoogleLoginProvider = "Google"; + private const string DefaultRole = "User"; + + public async Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken) + { + GoogleJsonWebSignature.Payload payload = await ValidateGoogleTokenAsync(signInDto.IdToken); + ApplicationUser user = await FindOrCreateUserAsync(payload, cancellationToken); + IList roles = await userManager.GetRolesAsync(user); + DateTimeOffset expiresAt = DateTimeOffset.UtcNow.AddMinutes(appSettings.Authentication.Jwt.ExpirationMinutes); + + return new AuthTokenDto + { + AccessToken = CreateAccessToken(user, payload.Name, roles, expiresAt), + ExpiresAt = expiresAt + }; + } + + public Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken) + { + string userId = principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; + string email = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty; + string name = principal.FindFirstValue(ClaimTypes.Name) ?? string.Empty; + string[] roles = principal.FindAll(ClaimTypes.Role) + .Select(claim => claim.Value) + .ToArray(); + + return Task.FromResult(new CurrentUserDto + { + UserId = userId, + Email = email, + Name = name, + Roles = roles + }); + } + + private async Task ValidateGoogleTokenAsync(string idToken) + { + GoogleJsonWebSignature.ValidationSettings validationSettings = new() + { + Audience = [appSettings.Authentication.Google.ClientId] + }; + + return await GoogleJsonWebSignature.ValidateAsync(idToken, validationSettings); + } + + private async Task FindOrCreateUserAsync( + GoogleJsonWebSignature.Payload payload, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(payload.Email) || !payload.EmailVerified) + { + throw new UnauthorizedAccessException("Google account email must be verified."); + } + + ApplicationUser? user = await userManager.FindByLoginAsync(GoogleLoginProvider, payload.Subject); + + if (user == null) + { + user = await userManager.FindByEmailAsync(payload.Email); + } + + if (user == null) + { + user = new ApplicationUser + { + UserName = payload.Email, + Email = payload.Email, + EmailConfirmed = payload.EmailVerified + }; + + IdentityResult createResult = await userManager.CreateAsync(user); + ThrowIfFailed(createResult); + } + + UserLoginInfo googleLogin = new(GoogleLoginProvider, payload.Subject, GoogleLoginProvider); + IList logins = await userManager.GetLoginsAsync(user); + + if (!logins.Any(login => login.LoginProvider == GoogleLoginProvider && login.ProviderKey == payload.Subject)) + { + IdentityResult loginResult = await userManager.AddLoginAsync(user, googleLogin); + ThrowIfFailed(loginResult); + } + + await EnsureDefaultRoleAsync(user); + + return user; + } + + private async Task EnsureDefaultRoleAsync(ApplicationUser user) + { + if (!await roleManager.RoleExistsAsync(DefaultRole)) + { + IdentityResult roleResult = await roleManager.CreateAsync(new IdentityRole(DefaultRole)); + ThrowIfFailed(roleResult); + } + + if (!await userManager.IsInRoleAsync(user, DefaultRole)) + { + IdentityResult userRoleResult = await userManager.AddToRoleAsync(user, DefaultRole); + ThrowIfFailed(userRoleResult); + } + } + + private string CreateAccessToken( + ApplicationUser user, + string? googleName, + IEnumerable roles, + DateTimeOffset expiresAt) + { + JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt; + SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(jwtSettings.SigningKey)); + SigningCredentials signingCredentials = new(securityKey, SecurityAlgorithms.HmacSha256); + List claims = + [ + new(JwtRegisteredClaimNames.Sub, user.Id), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Email, user.Email ?? string.Empty), + new(ClaimTypes.Name, googleName ?? user.UserName ?? string.Empty) + ]; + + claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); + + JwtSecurityToken token = new( + issuer: jwtSettings.Issuer, + audience: jwtSettings.Audience, + claims: claims, + expires: expiresAt.UtcDateTime, + signingCredentials: signingCredentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + private static void ThrowIfFailed(IdentityResult result) + { + if (result.Succeeded) + { + return; + } + + string errors = string.Join("; ", result.Errors.Select(error => error.Description)); + throw new InvalidOperationException(errors); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs b/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs new file mode 100644 index 0000000..b1d19b2 --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs @@ -0,0 +1,14 @@ +namespace CleanApiStarter.Infrastructure.Persistence; + +public sealed class ApplicationDbContext(DbContextOptions options) + : IdentityDbContext(options) +{ + public DbSet Words => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs new file mode 100644 index 0000000..61ee5f4 --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs @@ -0,0 +1,40 @@ +namespace CleanApiStarter.Infrastructure.Persistence.Configuration; + +public sealed class WordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("words"); + + builder.HasKey(word => word.Id); + + builder.Property(word => word.Id) + .HasColumnName("id"); + + builder.Property(word => word.Text) + .HasColumnName("text") + .HasMaxLength(100) + .IsRequired(); + + builder.Property(word => word.Meaning) + .HasColumnName("meaning") + .IsRequired(); + + builder.Property(word => word.Synonyms) + .HasColumnName("synonyms") + .HasColumnType("jsonb") + .HasConversion( + synonyms => JsonSerializer.Serialize(synonyms, JsonSerializerOptions.Default), + value => JsonSerializer.Deserialize>(value, JsonSerializerOptions.Default) ?? new List()); + + builder.Property(word => word.UsageExample) + .HasColumnName("usage_example") + .IsRequired(); + + builder.Property(word => word.CreatedAt) + .HasColumnName("created_at"); + + builder.Property(word => word.UpdatedAt) + .HasColumnName("updated_at"); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs index ce6b6cf..d2c6334 100644 --- a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs +++ b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs @@ -1,121 +1,44 @@ namespace CleanApiStarter.Infrastructure.Repositories; -public class WordRepository(IDatabaseConnectionFactory connectionFactory) : IWordRepository +public class WordRepository(ApplicationDbContext dbContext) : IWordRepository { public async Task AddWordAsync(Word word, CancellationToken cancellationToken) { - const string sql = - """ - INSERT INTO words (id, text, meaning, synonyms, usage_example, created_at, updated_at) - VALUES (@Id, @Text, @Meaning, @Synonyms::jsonb, @UsageExample, @CreatedAt, @UpdatedAt) - RETURNING id - """; + dbContext.Words.Add(word); + await dbContext.SaveChangesAsync(cancellationToken); - using IDbConnection connection = connectionFactory.CreateConnection(); - - var parameters = new - { - word.Id, - word.Text, - word.Meaning, - Synonyms = JsonSerializer.Serialize(word.Synonyms), - word.UsageExample, - word.CreatedAt, - word.UpdatedAt - }; - - Guid id = await connection.ExecuteScalarAsync(sql, parameters); - return id; + return word.Id; } public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken) { - const string sql = - """ - SELECT id, text, meaning, synonyms::text as SynonymsJson, usage_example as UsageExample, created_at as CreatedAt, updated_at as UpdatedAt - FROM words - WHERE id = @Id - """; - - using IDbConnection connection = connectionFactory.CreateConnection(); - WordRow? word = await connection.QuerySingleOrDefaultAsync(sql, new { Id = id }); - - return word?.ToDomain(); + return await dbContext.Words + .AsNoTracking() + .SingleOrDefaultAsync(word => word.Id == id, cancellationToken); } public async Task> GetAllWordsAsync(CancellationToken cancellationToken) { - const string sql = - """ - SELECT id, text, meaning, synonyms::text as SynonymsJson, usage_example as UsageExample, created_at as CreatedAt, updated_at as UpdatedAt - FROM words - """; - - using IDbConnection connection = connectionFactory.CreateConnection(); - IEnumerable words = await connection.QueryAsync(sql); - - return words.Select(word => word.ToDomain()); + return await dbContext.Words + .AsNoTracking() + .OrderBy(word => word.Text) + .ToListAsync(cancellationToken); } public async Task UpdateWordAsync(Word word, CancellationToken cancellationToken) { - const string sql = - """ - UPDATE words - SET text = @Text, - meaning = @Meaning, - synonyms = @Synonyms, - usage_example = @UsageExample, - updated_at = @UpdatedAt - WHERE id = @Id - """; + dbContext.Words.Update(word); + int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - using IDbConnection connection = connectionFactory.CreateConnection(); - var parameters = new - { - word.Id, - word.Text, - word.Meaning, - Synonyms = JsonSerializer.Serialize(word.Synonyms), - word.UsageExample, - word.UpdatedAt - }; - - int rowsAffected = await connection.ExecuteAsync(sql, parameters); return rowsAffected > 0; } public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken) { - const string sql = "DELETE FROM words WHERE id = @Id"; + int rowsAffected = await dbContext.Words + .Where(word => word.Id == id) + .ExecuteDeleteAsync(cancellationToken); - using IDbConnection connection = connectionFactory.CreateConnection(); - int rowsAffected = await connection.ExecuteAsync(sql, new { Id = id }); return rowsAffected > 0; } - - private sealed class WordRow - { - public Guid Id { get; init; } - public string Text { get; init; } = string.Empty; - public string Meaning { get; init; } = string.Empty; - public string SynonymsJson { get; init; } = string.Empty; - public string UsageExample { get; init; } = string.Empty; - public DateTime CreatedAt { get; init; } - public DateTime? UpdatedAt { get; init; } - - public Word ToDomain() - { - return new Word - { - Id = Id, - Text = Text, - Meaning = Meaning, - Synonyms = JsonSerializer.Deserialize>(SynonymsJson) ?? [], - UsageExample = UsageExample, - CreatedAt = CreatedAt, - UpdatedAt = UpdatedAt - }; - } - } } \ No newline at end of file diff --git a/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs b/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs deleted file mode 100644 index 356aa00..0000000 --- a/tests/CleanApiStarter.UnitTests/Controllers/WordsControllerTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -// using CleanApiStarter.Api.Controllers; -// using CleanApiStarter.Application.Interfaces; -// using CleanApiStarter.Application.Models; -// using AutoFixture.Xunit3; -// using FluentAssertions; -// using Microsoft.AspNetCore.Mvc; -// using NSubstitute; -// using Xunit; -// -// namespace CleanApiStarter.UnitTests.Controllers; -// -// public class WordsControllerTests -// { -// private readonly IWordService _wordService; -// private readonly WordsController _sut; -// -// public WordsControllerTests() -// { -// _wordService = Substitute.For(); -// _sut = new WordsController(_wordService); -// } -// -// [Theory, AutoData] -// public async Task CreateWord_ShouldReturnCreatedAtAction(CreateWordDto wordDto, Guid wordId) -// { -// // Arrange -// _wordService.AddWordAsync(wordDto).Returns(wordId); -// -// // Act -// var result = await _sut.CreateWord(wordDto); -// -// // Assert -// var createdAtActionResult = result.Result.Should().BeOfType().Subject; -// createdAtActionResult.ActionName.Should().Be(nameof(WordsController.GetWord)); -// createdAtActionResult.RouteValues!.Should().ContainKey("id").WhoseValue.Should().Be(wordId); -// createdAtActionResult.Value.Should().Be(wordId); -// } -// -// [Theory, AutoData] -// public async Task GetWord_WhenWordExists_ShouldReturnOkWithWord(Guid wordId, WordDto wordDto) -// { -// // Arrange -// _wordService.GetWordByIdAsync(wordId).Returns(wordDto); -// -// // Act -// var result = await _sut.GetWord(wordId); -// -// // Assert -// var okResult = result.Result.Should().BeOfType().Subject; -// okResult.Value.Should().Be(wordDto); -// } -// -// [Theory, AutoData] -// public async Task GetWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId) -// { -// // Arrange -// _wordService.GetWordByIdAsync(wordId).Returns((WordDto?)null); -// -// // Act -// var result = await _sut.GetWord(wordId); -// -// // Assert -// result.Result.Should().BeOfType(); -// } -// -// [Theory, AutoData] -// public async Task GetAllWords_ShouldReturnOkWithAllWords(List words) -// { -// // Arrange -// _wordService.GetAllWordsAsync().Returns(words); -// -// // Act -// var result = await _sut.GetAllWords(); -// -// // Assert -// var okResult = result.Result.Should().BeOfType().Subject; -// okResult.Value.Should().Be(words); -// } -// -// [Theory, AutoData] -// public async Task UpdateWord_WhenWordExists_ShouldReturnNoContent(Guid wordId, CreateWordDto updateDto) -// { -// // Arrange -// _wordService.UpdateWordAsync(wordId, updateDto).Returns(true); -// -// // Act -// var result = await _sut.UpdateWord(wordId, updateDto); -// -// // Assert -// result.Should().BeOfType(); -// } -// -// [Theory, AutoData] -// public async Task UpdateWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId, CreateWordDto updateDto) -// { -// // Arrange -// _wordService.UpdateWordAsync(wordId, updateDto).Returns(false); -// -// // Act -// var result = await _sut.UpdateWord(wordId, updateDto); -// -// // Assert -// result.Should().BeOfType(); -// } -// -// [Theory, AutoData] -// public async Task DeleteWord_WhenWordExists_ShouldReturnNoContent(Guid wordId) -// { -// // Arrange -// _wordService.DeleteWordAsync(wordId).Returns(true); -// -// // Act -// var result = await _sut.DeleteWord(wordId); -// -// // Assert -// result.Should().BeOfType(); -// } -// -// [Theory, AutoData] -// public async Task DeleteWord_WhenWordDoesNotExist_ShouldReturnNotFound(Guid wordId) -// { -// // Arrange -// _wordService.DeleteWordAsync(wordId).Returns(false); -// -// // Act -// var result = await _sut.DeleteWord(wordId); -// -// // Assert -// result.Should().BeOfType(); -// } -// } \ No newline at end of file diff --git a/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs b/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs deleted file mode 100644 index 50d024d..0000000 --- a/tests/CleanApiStarter.UnitTests/Repositories/WordRepositoryTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -// using System.Data; -// using AutoFixture.Xunit3; -// using CleanApiStarter.Domain.Entities; -// using FluentAssertions; -// using CleanApiStarter.Infrastructure.Database; -// using CleanApiStarter.Infrastructure.Repositories; -// using NSubstitute; -// using Xunit; -// -// namespace CleanApiStarter.UnitTests.Repositories; -// -// public class WordRepositoryTests -// { -// private readonly IDatabaseConnectionFactory _connectionFactory; -// private readonly IDbConnection _connection; -// private readonly WordRepository _sut; -// -// public WordRepositoryTests() -// { -// _connection = Substitute.For(); -// _connectionFactory = Substitute.For(); -// _connectionFactory.CreateConnection().Returns(_connection); -// -// _sut = new WordRepository(_connectionFactory); -// } -// -// [Theory, AutoData] -// public async Task AddWordAsync_ShouldOpenConnectionAndExecuteCommand(Word word) -// { -// // This test is a basic structure to show how to mock Dapper -// // Full implementation would require more complex mocking of connection and command -// -// // Arrange -// var expectedId = word.Id; -// -// // Act -// await _sut.AddWordAsync(word); -// -// // Assert -// _connectionFactory.Received(1).CreateConnection(); -// } -// -// [Theory, AutoData] -// public async Task GetWordByIdAsync_ShouldOpenConnectionAndExecuteQuery(Guid id) -// { -// // This test is a basic structure to show how to mock Dapper -// // Full implementation would require more complex mocking of connection and query -// -// // Arrange -// -// // Act -// await _sut.GetWordByIdAsync(id); -// -// // Assert -// _connectionFactory.Received(1).CreateConnection(); -// } -// -// // Additional tests would follow the same pattern -// // For a real implementation, you would need to mock Dapper's extension methods -// // which is more complex and may require a test database or more sophisticated mocking -// } \ No newline at end of file diff --git a/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs b/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs deleted file mode 100644 index 601e4a5..0000000 --- a/tests/CleanApiStarter.UnitTests/Services/WordServiceTests.cs +++ /dev/null @@ -1,139 +0,0 @@ -// using CleanApiStarter.Application.Interfaces; -// using CleanApiStarter.Application.Models; -// using CleanApiStarter.Application.Services; -// -// using CleanApiStarter.Domain.Entities; -// -// using NSubstitute; -// -// using Shouldly; -// -// using Xunit; -// -// namespace CleanApiStarter.UnitTests.Services; -// -// public class WordServiceTests -// { -// private readonly IWordRepository _wordRepository; -// private readonly WordService _sut; -// -// public WordServiceTests() -// { -// _wordRepository = Substitute.For(); -// _sut = new WordService(_wordRepository); -// } -// -// [Theory, AutoData] -// public async Task AddWordAsync_ShouldMapDtoToDomainAndCallRepository(CreateWordDto wordDto) -// { -// // Arrange -// var expectedId = Guid.NewGuid(); -// _wordRepository.AddWordAsync(Arg.Any()).Returns(expectedId); -// -// // Act -// var result = await _sut.AddWordAsync(wordDto); -// -// // Assert -// result.ShouldBe(expectedId); -// await _wordRepository.Received(1).AddWordAsync(Arg.Is(w => -// w.Text == wordDto.Text && -// w.Meaning == wordDto.Meaning && -// w.Synonyms.SequenceEqual(wordDto.Synonyms) && -// w.UsageExample == wordDto.UsageExample)); -// } -// -// [Theory, AutoData] -// public async Task GetWordByIdAsync_WhenWordExists_ShouldReturnMappedDto(Guid id, Word word) -// { -// // Arrange -// _wordRepository.GetWordByIdAsync(id).Returns(word); -// -// // Act -// var result = await _sut.GetWordByIdAsync(id); -// -// // Assert -// result.Should().NotBeNull(); -// result!.Id.Should().Be(word.Id); -// result.Text.Should().Be(word.Text); -// result.Meaning.Should().Be(word.Meaning); -// result.Synonyms.Should().BeEquivalentTo(word.Synonyms); -// result.UsageExample.Should().Be(word.UsageExample); -// } -// -// [Theory, AutoData] -// public async Task GetWordByIdAsync_WhenWordDoesNotExist_ShouldReturnNull(Guid id) -// { -// // Arrange -// _wordRepository.GetWordByIdAsync(id).Returns((Word?)null); -// -// // Act -// var result = await _sut.GetWordByIdAsync(id); -// -// // Assert -// result.Should().BeNull(); -// } -// -// [Theory, AutoData] -// public async Task GetAllWordsAsync_ShouldReturnMappedDtos(List words) -// { -// // Arrange -// _wordRepository.GetAllWordsAsync().Returns(words); -// -// // Act -// var result = await _sut.GetAllWordsAsync(); -// -// // Assert -// result.Should().HaveCount(words.Count); -// result.Select(dto => dto.Id).Should().BeEquivalentTo(words.Select(w => w.Id)); -// } -// -// [Theory, AutoData] -// public async Task UpdateWordAsync_WhenWordExists_ShouldUpdateAndReturnTrue(Guid id, CreateWordDto updateDto, Word existingWord) -// { -// // Arrange -// existingWord.Id = id; -// _wordRepository.GetWordByIdAsync(id).Returns(existingWord); -// _wordRepository.UpdateWordAsync(Arg.Any()).Returns(true); -// -// // Act -// var result = await _sut.UpdateWordAsync(id, updateDto); -// -// // Assert -// result.Should().BeTrue(); -// await _wordRepository.Received(1).UpdateWordAsync(Arg.Is(w => -// w.Id == id && -// w.Text == updateDto.Text && -// w.Meaning == updateDto.Meaning && -// w.Synonyms.SequenceEqual(updateDto.Synonyms) && -// w.UsageExample == updateDto.UsageExample && -// w.UpdatedAt != null)); -// } -// -// [Theory, AutoData] -// public async Task UpdateWordAsync_WhenWordDoesNotExist_ShouldReturnFalse(Guid id, CreateWordDto updateDto) -// { -// // Arrange -// _wordRepository.GetWordByIdAsync(id).Returns((Word?)null); -// -// // Act -// var result = await _sut.UpdateWordAsync(id, updateDto); -// -// // Assert -// result.Should().BeFalse(); -// await _wordRepository.DidNotReceive().UpdateWordAsync(Arg.Any()); -// } -// -// [Theory, AutoData] -// public async Task DeleteWordAsync_ShouldCallRepositoryAndReturnResult(Guid id, bool expectedResult) -// { -// // Arrange -// _wordRepository.DeleteWordAsync(id).Returns(expectedResult); -// -// // Act -// var result = await _sut.DeleteWordAsync(id); -// -// // Assert -// result.Should().Be(expectedResult); -// await _wordRepository.Received(1).DeleteWordAsync(id); -// } -// } \ No newline at end of file From d4111733c7b77243b15609cb0bf70e517e95d6cf Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Fri, 1 May 2026 10:10:16 +0800 Subject: [PATCH 06/33] implement pagination and validation for word endpoints, add response compression and request ID middleware --- AGENTS.md | 20 ++++++--- Directory.Packages.props | 8 +++- README.md | 7 +++ src/CleanApiStarter.Api/Api.http | 5 ++- .../CleanApiStarter.Api.csproj | 2 + .../Endpoints/GoogleLoginPage.cs | 39 +++++++++++++++- src/CleanApiStarter.Api/Endpoints/V1/Words.cs | 9 ++-- src/CleanApiStarter.Api/Endpoints/V2/Words.cs | 16 ++++--- src/CleanApiStarter.Api/GlobalUsings.cs | 5 ++- src/CleanApiStarter.Api/Program.cs | 38 ++++++++++------ .../CleanApiStarter.Application.csproj | 2 + .../Common/Models/ArrayResult.cs | 8 ++++ .../Common/Models/PaginatedQuery.cs | 8 ++++ .../Common/Models/PaginatedQueryValidator.cs | 13 ++++++ .../Common/Models/PaginatedResult.cs | 27 +++++++++++ .../DependencyInjection.cs | 2 + .../Features/Auth/GoogleSignInDtoValidator.cs | 10 +++++ .../Features/Words/CreateWordDtoValidator.cs | 24 ++++++++++ .../Features/Words/IWordRepository.cs | 2 +- .../Features/Words/IWordService.cs | 2 +- .../Features/Words/WordService.cs | 16 ++++--- .../GlobalUsings.cs | 3 ++ .../AspNetCoreDefaultServices.cs | 37 ++++++++++++++- .../CleanApiStarter.AspNetCore.csproj | 3 ++ src/CleanApiStarter.AspNetCore/Extensions.cs | 3 ++ .../GlobalUsings.cs | 13 ++++-- .../RequestIdMiddleware.cs | 23 ++++++++++ .../ValidationFilter.cs | 45 +++++++++++++++++++ .../WebApplicationExtensions.cs | 3 +- .../GlobalUsings.cs | 1 + .../Repositories/WordRepository.cs | 19 ++++++-- 31 files changed, 362 insertions(+), 51 deletions(-) create mode 100644 src/CleanApiStarter.Application/Common/Models/ArrayResult.cs create mode 100644 src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs create mode 100644 src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs create mode 100644 src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs create mode 100644 src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs create mode 100644 src/CleanApiStarter.Application/Features/Words/CreateWordDtoValidator.cs create mode 100644 src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs create mode 100644 src/CleanApiStarter.AspNetCore/ValidationFilter.cs diff --git a/AGENTS.md b/AGENTS.md index edbeebc..9e6e59d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,11 +51,19 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Organize the `Application` project by feature. Put feature-specific contracts, DTOs, and application services under folders such as `Application/Features/Words` or `Application/Features/Auth`. - Do not create generic `Application/Services` or `Application/Models` folders for feature-specific code. - Keep cross-cutting application abstractions under `Application/Common`, for example `Application/Common/Interfaces`. +- Return single resources as their DTO object directly. +- Use `ArrayResult` instead of returning raw arrays from non-paginated list endpoints. +- Use `PaginatedQuery` for paginated request parameters and return `PaginatedResult` directly for paginated collection responses. +- Use FluentValidation for request validation. Put validators beside feature request models in `Application`, and rely on the shared Minimal API validation endpoint filter in `CleanApiStarter.AspNetCore`. FluentValidation failures should return `422 Unprocessable Entity`. +- Do not use DataAnnotations for application request validation. - Use Scalar, not Swagger/Swashbuckle. -- Use `Microsoft.AspNetCore.OpenApi` with: - - `builder.Services.AddOpenApi();` - - `app.MapOpenApi();` - - `app.MapScalarApiReference();` +- Use the .NET 10 API versioning/OpenAPI setup from the Microsoft .NET blog: + - `Asp.Versioning.Http` v10 + - `Asp.Versioning.Mvc.ApiExplorer` v10 + - `Asp.Versioning.OpenApi` + - `builder.Services.AddApiVersioning(...).AddApiExplorer(...).AddOpenApi();` + - `app.MapOpenApi().WithDocumentPerVersion();` + - `app.MapScalarApiReference(...)` configured from `app.DescribeApiVersions()` - Do not add `Swashbuckle.AspNetCore`. - Cancellation tokens are explicit: - Do not use `CancellationToken cancellationToken = default` in service or repository contracts. @@ -97,6 +105,8 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - `app.MapDefaultEndpoints();` - Shared middleware such as HTTP request logging belongs in `CleanApiStarter.AspNetCore`, not duplicated inside each API project. - Keep OpenTelemetry logs configured to include scopes, formatted messages, and parsed state values so structured message-template properties show up in Aspire. +- Responses should include `X-Request-ID` with the current trace id, configured centrally in `CleanApiStarter.AspNetCore`. +- Response compression should be configured centrally in `CleanApiStarter.AspNetCore` with Brotli and gzip providers. - Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{WordId}` and `{WordCount}`. - Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly when services need configuration values. - Do not add generic options registration helpers until the template has multiple real options sections that need them. @@ -111,7 +121,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Use built-in Minimal API mapping methods with explicit `.WithName(...)`; do not add custom `MapGet`/`MapPost` overloads that shadow framework methods. - API versions are selected with the optional `X-Api-Version` request header. Missing versions default to v1. - Endpoint groups should declare `MajorVersion` to match their folder, for example `V1` uses `1` and `V2` uses `2`. -- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetAllWordsV1` and `GetAllWordsV2`. +- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetWordsV1` and `GetWordsV2`. - Do not reintroduce MVC controllers unless the template intentionally changes direction. ## Authentication diff --git a/Directory.Packages.props b/Directory.Packages.props index 601f3d5..fa34cb3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,15 +10,19 @@ - + + + + + - + diff --git a/README.md b/README.md index 5a2c448..005a459 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,9 @@ A Clean Architecture API starter template for .NET, Aspire, PostgreSQL, and Open The API uses Minimal APIs with endpoint group classes. `CleanApiStarter.AspNetCore` discovers endpoint groups from the API assembly so `Program.cs` stays small while routes remain easy to find. API versions are selected with the optional `X-Api-Version` request header. Requests without a version use v1 by default. Endpoint groups live under version folders such as `Endpoints/V1` and `Endpoints/V2`. +OpenAPI documents are generated per API version and shown in Scalar with a version selector. + +Collection endpoints return an `ArrayResult` envelope when they are not paginated. Paginated endpoints accept `limit` and `offset` query parameters and return `PaginatedResult` metadata with the items. Single-resource endpoints return the resource object directly. ## Authentication @@ -59,6 +62,8 @@ After the dashboard is running, send requests to the API through Scalar or anoth - Logs show structured application and framework log entries. The word use cases emit fields such as `WordId`, `SynonymCount`, `WordCount`, and `UpdateSucceeded`. - Request logs show method, path, response status code, duration, and `UserId` without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`. +- Responses include an `X-Request-ID` header containing the current OpenTelemetry trace id, which can be used to correlate API responses with Aspire traces and logs. +- Responses support Brotli and gzip compression when clients send an `Accept-Encoding` header. - Traces show incoming HTTP requests and PostgreSQL commands from Npgsql. - Metrics show ASP.NET Core, HTTP client, and .NET runtime measurements. - `/version` exposes the running application version. @@ -72,6 +77,8 @@ Use structured logging message templates instead of interpolated log strings. Pr Configuration classes live in `CleanApiStarter.Configuration`. Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly where configuration values are needed. +Request validation uses FluentValidation. Validators live beside their feature request models in the `Application` project and are executed by a Minimal API endpoint filter from `CleanApiStarter.AspNetCore`. FluentValidation failures return `422 Unprocessable Entity`. + ### Run with Docker Compose Only If you want to run PostgreSQL without Aspire: diff --git a/src/CleanApiStarter.Api/Api.http b/src/CleanApiStarter.Api/Api.http index 7deb1e4..66e410b 100644 --- a/src/CleanApiStarter.Api/Api.http +++ b/src/CleanApiStarter.Api/Api.http @@ -1,8 +1,9 @@ @CleanApiStarter_Api_HostAddress = https://localhost:7285 @AccessToken = paste-api-jwt-here -GET {{CleanApiStarter_Api_HostAddress}}/api/words +GET {{CleanApiStarter_Api_HostAddress}}/api/words?limit=20&offset=0 Accept: application/json +Authorization: Bearer {{AccessToken}} ### @@ -34,7 +35,7 @@ Authorization: Bearer {{AccessToken}} ### -GET {{CleanApiStarter_Api_HostAddress}}/api/words +GET {{CleanApiStarter_Api_HostAddress}}/api/words?limit=20&offset=0 Accept: application/json X-Api-Version: 2.0 Authorization: Bearer {{AccessToken}} diff --git a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj index 47c56e1..c710c1c 100644 --- a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +++ b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj @@ -7,6 +7,8 @@ + + diff --git a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs index e57f2ec..a36d7cd 100644 --- a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs +++ b/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs @@ -24,6 +24,10 @@ public static WebApplication MapGoogleLoginPage(this WebApplication app) @@ -34,10 +38,16 @@ public static WebApplication MapGoogleLoginPage(this WebApplication app) data-callback="handleCredentialResponse"> -

API JWT

+
+

API JWT

+ + +
Sign in with Google to generate a token.
@@ -62,4 +97,4 @@ async function handleCredentialResponse(response) { return app; } -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Words.cs b/src/CleanApiStarter.Api/Endpoints/V1/Words.cs index 0b8c011..6396eb3 100644 --- a/src/CleanApiStarter.Api/Endpoints/V1/Words.cs +++ b/src/CleanApiStarter.Api/Endpoints/V1/Words.cs @@ -13,8 +13,8 @@ public static void Map(RouteGroupBuilder groupBuilder) groupBuilder.MapPost("/", CreateWord) .WithName("CreateWordV1"); - groupBuilder.MapGet("/", GetAllWords) - .WithName("GetAllWordsV1"); + groupBuilder.MapGet("/", GetWords) + .WithName("GetWordsV1"); groupBuilder.MapGet("/{id:guid}", GetWord) .WithName("GetWordV1"); @@ -46,11 +46,12 @@ private static async Task GetWord( return word == null ? Results.NotFound() : Results.Ok(word); } - private static async Task GetAllWords( + private static async Task GetWords( + [AsParameters] PaginatedQuery query, IWordService wordService, CancellationToken cancellationToken) { - IEnumerable words = await wordService.GetAllWordsAsync(cancellationToken); + PaginatedResult words = await wordService.GetWordsAsync(query, cancellationToken); return Results.Ok(words); } diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Words.cs b/src/CleanApiStarter.Api/Endpoints/V2/Words.cs index 27bd502..6c9cbb3 100644 --- a/src/CleanApiStarter.Api/Endpoints/V2/Words.cs +++ b/src/CleanApiStarter.Api/Endpoints/V2/Words.cs @@ -10,20 +10,26 @@ public static void Map(RouteGroupBuilder groupBuilder) { groupBuilder.RequireAuthorization(); - groupBuilder.MapGet("/", GetAllWords) - .WithName("GetAllWordsV2"); + groupBuilder.MapGet("/", GetWords) + .WithName("GetWordsV2"); } - private static async Task GetAllWords( + private static async Task GetWords( + [AsParameters] PaginatedQuery query, IWordService wordService, CancellationToken cancellationToken) { - IEnumerable words = await wordService.GetAllWordsAsync(cancellationToken); + PaginatedResult words = await wordService.GetWordsAsync(query, cancellationToken); return Results.Ok(new { ApiVersion = "2.0", - Items = words + words.Items, + words.Limit, + words.Offset, + words.TotalCount, + words.HasPreviousPage, + words.HasNextPage }); } } \ No newline at end of file diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs index 43234fb..72e709e 100644 --- a/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -4,10 +4,13 @@ global using System.Security.Claims; global using System.Text.Encodings.Web; +global using Asp.Versioning.ApiExplorer; + global using CleanApiStarter.Api.Endpoints; global using CleanApiStarter.Api.Services; global using CleanApiStarter.Application; global using CleanApiStarter.Application.Common.Interfaces; +global using CleanApiStarter.Application.Common.Models; global using CleanApiStarter.Application.Features.Auth; global using CleanApiStarter.Application.Features.Words; global using CleanApiStarter.AspNetCore; @@ -17,4 +20,4 @@ global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Routing; -global using Scalar.AspNetCore; \ No newline at end of file +global using Scalar.AspNetCore; diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs index d599159..ebb87d3 100644 --- a/src/CleanApiStarter.Api/Program.cs +++ b/src/CleanApiStarter.Api/Program.cs @@ -1,23 +1,33 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.AddAspNetCoreDefaults(); - -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddOpenApi(); - -builder.Services.AddAppSettings(builder.Configuration); -builder.Services.AddApplication(); -builder.Services.AddInfrastructure(); +builder.AddAspNetCoreDefaults(); + +builder.Services.AddAppSettings(builder.Configuration); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(); builder.Services.AddScoped(); WebApplication app = builder.Build(); // Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); - app.MapScalarApiReference(); -} +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi() + .WithDocumentPerVersion(); + + app.MapScalarApiReference(options => + { + IReadOnlyList descriptions = app.DescribeApiVersions(); + + for (int index = 0; index < descriptions.Count; index++) + { + ApiVersionDescription description = descriptions[index]; + bool isDefault = index == descriptions.Count - 1; + + options.AddDocument(description.GroupName, description.GroupName, isDefault: isDefault); + } + }); +} app.UseAspNetCoreDefaults(); app.UseHttpsRedirection(); @@ -25,4 +35,4 @@ app.MapEndpoints(Assembly.GetExecutingAssembly()); app.MapDefaultEndpoints(); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj b/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj index 8b8bb4d..828befe 100644 --- a/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj +++ b/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj @@ -6,6 +6,8 @@ + + diff --git a/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs b/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs new file mode 100644 index 0000000..a75bfe8 --- /dev/null +++ b/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Application.Common.Models; + +public sealed class ArrayResult +{ + public required IReadOnlyCollection Items { get; init; } + + public int Count => Items.Count; +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs b/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs new file mode 100644 index 0000000..eb30234 --- /dev/null +++ b/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Application.Common.Models; + +public sealed class PaginatedQuery +{ + public int Limit { get; set; } = 20; + + public int Offset { get; set; } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs b/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs new file mode 100644 index 0000000..fe83865 --- /dev/null +++ b/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs @@ -0,0 +1,13 @@ +namespace CleanApiStarter.Application.Common.Models; + +public sealed class PaginatedQueryValidator : AbstractValidator +{ + public PaginatedQueryValidator() + { + RuleFor(query => query.Limit) + .InclusiveBetween(1, 100); + + RuleFor(query => query.Offset) + .GreaterThanOrEqualTo(0); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs b/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs new file mode 100644 index 0000000..83f9be0 --- /dev/null +++ b/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs @@ -0,0 +1,27 @@ +namespace CleanApiStarter.Application.Common.Models; + +public sealed class PaginatedResult +{ + public required IReadOnlyCollection Items { get; init; } + + public required int Limit { get; init; } + + public required int Offset { get; init; } + + public required int TotalCount { get; init; } + + public bool HasPreviousPage => Offset > 0; + + public bool HasNextPage => Offset + Limit < TotalCount; + + public PaginatedResult Map(Func map) + { + return new PaginatedResult + { + Items = Items.Select(map).ToArray(), + Limit = Limit, + Offset = Offset, + TotalCount = TotalCount + }; + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/DependencyInjection.cs b/src/CleanApiStarter.Application/DependencyInjection.cs index 53567a8..055467a 100644 --- a/src/CleanApiStarter.Application/DependencyInjection.cs +++ b/src/CleanApiStarter.Application/DependencyInjection.cs @@ -4,7 +4,9 @@ public static class DependencyInjection { public static IServiceCollection AddApplication(this IServiceCollection services) { + services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); services.AddScoped(); + return services; } } \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs new file mode 100644 index 0000000..4f52587 --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs @@ -0,0 +1,10 @@ +namespace CleanApiStarter.Application.Features.Auth; + +public sealed class GoogleSignInDtoValidator : AbstractValidator +{ + public GoogleSignInDtoValidator() + { + RuleFor(signIn => signIn.IdToken) + .NotEmpty(); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Words/CreateWordDtoValidator.cs b/src/CleanApiStarter.Application/Features/Words/CreateWordDtoValidator.cs new file mode 100644 index 0000000..5e437a2 --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Words/CreateWordDtoValidator.cs @@ -0,0 +1,24 @@ +namespace CleanApiStarter.Application.Features.Words; + +public sealed class CreateWordDtoValidator : AbstractValidator +{ + public CreateWordDtoValidator() + { + RuleFor(word => word.Text) + .NotEmpty() + .MaximumLength(100); + + RuleFor(word => word.Meaning) + .NotEmpty(); + + RuleFor(word => word.Synonyms) + .NotNull(); + + RuleForEach(word => word.Synonyms) + .NotEmpty() + .MaximumLength(100); + + RuleFor(word => word.UsageExample) + .NotEmpty(); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Words/IWordRepository.cs b/src/CleanApiStarter.Application/Features/Words/IWordRepository.cs index 75aac8c..1f9bf33 100644 --- a/src/CleanApiStarter.Application/Features/Words/IWordRepository.cs +++ b/src/CleanApiStarter.Application/Features/Words/IWordRepository.cs @@ -4,7 +4,7 @@ public interface IWordRepository { Task AddWordAsync(Word word, CancellationToken cancellationToken); Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); - Task> GetAllWordsAsync(CancellationToken cancellationToken); + Task> GetWordsAsync(PaginatedQuery query, CancellationToken cancellationToken); Task UpdateWordAsync(Word word, CancellationToken cancellationToken); Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Words/IWordService.cs b/src/CleanApiStarter.Application/Features/Words/IWordService.cs index 8797d87..2e19a71 100644 --- a/src/CleanApiStarter.Application/Features/Words/IWordService.cs +++ b/src/CleanApiStarter.Application/Features/Words/IWordService.cs @@ -4,7 +4,7 @@ public interface IWordService { Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken); Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); - Task> GetAllWordsAsync(CancellationToken cancellationToken); + Task> GetWordsAsync(PaginatedQuery query, CancellationToken cancellationToken); Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken); Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Words/WordService.cs b/src/CleanApiStarter.Application/Features/Words/WordService.cs index ceb05cd..d05a729 100644 --- a/src/CleanApiStarter.Application/Features/Words/WordService.cs +++ b/src/CleanApiStarter.Application/Features/Words/WordService.cs @@ -41,14 +41,20 @@ public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken ca return MapToDto(word); } - public async Task> GetAllWordsAsync(CancellationToken cancellationToken) + public async Task> GetWordsAsync( + PaginatedQuery query, + CancellationToken cancellationToken) { - IEnumerable words = await wordRepository.GetAllWordsAsync(cancellationToken); - Word[] wordList = words.ToArray(); + PaginatedResult words = await wordRepository.GetWordsAsync(query, cancellationToken); + PaginatedResult wordDtos = words.Map(MapToDto); - logger.LogInformation("Retrieved {WordCount} words", wordList.Length); + logger.LogInformation( + "Retrieved {WordCount} words with limit {Limit} and offset {Offset}", + wordDtos.Items.Count, + wordDtos.Limit, + wordDtos.Offset); - return wordList.Select(MapToDto); + return wordDtos; } public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken) diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/src/CleanApiStarter.Application/GlobalUsings.cs index b92085b..1c481e2 100644 --- a/src/CleanApiStarter.Application/GlobalUsings.cs +++ b/src/CleanApiStarter.Application/GlobalUsings.cs @@ -1,7 +1,10 @@ global using System.Security.Claims; +global using CleanApiStarter.Application.Common.Models; global using CleanApiStarter.Application.Features.Words; global using CleanApiStarter.Domain.Entities; +global using FluentValidation; + global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Logging; \ No newline at end of file diff --git a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs index 101b619..2e7e5bc 100644 --- a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs +++ b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs @@ -1,3 +1,5 @@ +using Microsoft.AspNetCore.Authorization; + namespace CleanApiStarter.AspNetCore; public static partial class Extensions @@ -24,7 +26,12 @@ private static void AddApiVersioningDefaults(this IHostApplicationBuilder builde options.AssumeDefaultVersionWhenUnspecified = true; options.ReportApiVersions = true; options.ApiVersionReader = new HeaderApiVersionReader("X-Api-Version"); - }); + }) + .AddApiExplorer(options => + { + options.GroupNameFormat = "'v'VVV"; + }) + .AddOpenApi(); } private static void AddAuthenticationDefaults(this WebApplicationBuilder builder) @@ -32,7 +39,12 @@ private static void AddAuthenticationDefaults(this WebApplicationBuilder builder builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(); - builder.Services.AddAuthorization(); + builder.Services.AddAuthorization(options => + { + options.FallbackPolicy = new AuthorizationPolicyBuilder() + .RequireAuthenticatedUser() + .Build(); + }); builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) .Configure((options, appSettings) => @@ -70,6 +82,27 @@ private static void AddHttpLoggingDefaults(this IHostApplicationBuilder builder) builder.Services.AddHttpLoggingInterceptor(); } + private static void AddResponseCompressionDefaults(this IHostApplicationBuilder builder) + { + builder.Services.AddResponseCompression(options => + { + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); + options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( + [ + "application/json", + "application/problem+json" + ]); + }); + + builder.Services.Configure(options => + options.Level = CompressionLevel.Fastest); + + builder.Services.Configure(options => + options.Level = CompressionLevel.Fastest); + } + private static void AddServiceDiscoveryDefaults(this IHostApplicationBuilder builder) { builder.Services.AddServiceDiscovery(); diff --git a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj index cf25978..b0e57f8 100644 --- a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj +++ b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj @@ -12,6 +12,9 @@ + + + diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/src/CleanApiStarter.AspNetCore/Extensions.cs index dd2b540..7394f11 100644 --- a/src/CleanApiStarter.AspNetCore/Extensions.cs +++ b/src/CleanApiStarter.AspNetCore/Extensions.cs @@ -15,6 +15,7 @@ public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBui builder.AddProblemDetailsDefaults(); builder.AddApiVersioningDefaults(); builder.AddAuthenticationDefaults(); + builder.AddResponseCompressionDefaults(); builder.AddHttpLoggingDefaults(); builder.AddServiceDiscoveryDefaults(); builder.Services.AddHealthChecks() @@ -25,7 +26,9 @@ public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBui public static WebApplication UseAspNetCoreDefaults(this WebApplication app) { + app.UseMiddleware(); app.UseExceptionHandler(); + app.UseResponseCompression(); app.UseAuthentication(); app.UseHttpLogging(); app.UseAuthorization(); diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs index d12fd40..1e95eb5 100644 --- a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs +++ b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs @@ -1,13 +1,19 @@ +global using System.Diagnostics; global using System.IdentityModel.Tokens.Jwt; +global using System.IO.Compression; global using System.Reflection; global using System.Security.Claims; global using System.Text; -global using Asp.Versioning; -global using Asp.Versioning.Builder; +global using Asp.Versioning; +global using Asp.Versioning.ApiExplorer; +global using Asp.Versioning.Builder; global using CleanApiStarter.Configuration; +global using FluentValidation; +global using FluentValidation.Results; + global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Builder; global using Microsoft.AspNetCore.Diagnostics; @@ -15,6 +21,7 @@ global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.HttpLogging; global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.ResponseCompression; global using Microsoft.AspNetCore.Routing; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -26,4 +33,4 @@ global using OpenTelemetry.Logs; global using OpenTelemetry.Metrics; -global using OpenTelemetry.Trace; \ No newline at end of file +global using OpenTelemetry.Trace; diff --git a/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs b/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs new file mode 100644 index 0000000..0072cb2 --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs @@ -0,0 +1,23 @@ +namespace CleanApiStarter.AspNetCore; + +public sealed class RequestIdMiddleware(RequestDelegate next) +{ + public const string HeaderName = "X-Request-ID"; + + public async Task InvokeAsync(HttpContext context) + { + context.Response.OnStarting(() => + { + context.Response.Headers[HeaderName] = GetRequestId(context); + + return Task.CompletedTask; + }); + + await next(context); + } + + private static string GetRequestId(HttpContext context) + { + return Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier; + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.AspNetCore/ValidationFilter.cs b/src/CleanApiStarter.AspNetCore/ValidationFilter.cs new file mode 100644 index 0000000..ecaf242 --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/ValidationFilter.cs @@ -0,0 +1,45 @@ +namespace CleanApiStarter.AspNetCore; + +public sealed class ValidationFilter : IEndpointFilter +{ + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + List validationFailures = []; + + foreach (object? argument in context.Arguments) + { + if (argument == null) + { + continue; + } + + Type validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType()); + object? validator = context.HttpContext.RequestServices.GetService(validatorType); + + if (validator is not IValidator fluentValidator) + { + continue; + } + + ValidationContext validationContext = new(argument); + ValidationResult validationResult = await fluentValidator.ValidateAsync( + validationContext, + context.HttpContext.RequestAborted); + + validationFailures.AddRange(validationResult.Errors); + } + + if (validationFailures.Count == 0) + { + return await next(context); + } + + Dictionary errors = validationFailures + .GroupBy(failure => failure.PropertyName) + .ToDictionary( + group => group.Key, + group => group.Select(failure => failure.ErrorMessage).ToArray()); + + return Results.ValidationProblem(errors, statusCode: StatusCodes.Status422UnprocessableEntity); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs index 023c50b..e6a6a58 100644 --- a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs +++ b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs @@ -22,7 +22,8 @@ public static WebApplication MapEndpoints(this WebApplication app, Assembly asse RouteGroupBuilder group = app.MapGroup(routePrefix) .WithTags(groupName) .WithApiVersionSet(versionSet) - .MapToApiVersion(apiVersion); + .MapToApiVersion(apiVersion) + .AddEndpointFilter(); type.GetMethod(nameof(IEndpointGroup.Map))!.Invoke(null, [group]); } diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs index 8e0a629..553bced 100644 --- a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs +++ b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs @@ -3,6 +3,7 @@ global using System.Text; global using System.Text.Json; +global using CleanApiStarter.Application.Common.Models; global using CleanApiStarter.Application.Features.Auth; global using CleanApiStarter.Application.Features.Words; global using CleanApiStarter.Configuration; diff --git a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs index d2c6334..dcd5f3c 100644 --- a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs +++ b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs @@ -17,12 +17,25 @@ public async Task AddWordAsync(Word word, CancellationToken cancellationTo .SingleOrDefaultAsync(word => word.Id == id, cancellationToken); } - public async Task> GetAllWordsAsync(CancellationToken cancellationToken) + public async Task> GetWordsAsync(PaginatedQuery query, CancellationToken cancellationToken) { - return await dbContext.Words + IQueryable wordsQuery = dbContext.Words .AsNoTracking() - .OrderBy(word => word.Text) + .OrderBy(word => word.Text); + + int totalCount = await wordsQuery.CountAsync(cancellationToken); + List words = await wordsQuery + .Skip(query.Offset) + .Take(query.Limit) .ToListAsync(cancellationToken); + + return new PaginatedResult + { + Items = words, + Limit = query.Limit, + Offset = query.Offset, + TotalCount = totalCount + }; } public async Task UpdateWordAsync(Word word, CancellationToken cancellationToken) From 284be54f8287f3fd39e36aac9f5338531650509e Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Fri, 1 May 2026 18:29:17 +0800 Subject: [PATCH 07/33] implement project management features including project and task creation, retrieval, updating, and deletion; add necessary DTOs, validators, and repository interfaces --- AGENTS.md | 18 +- CleanApiStarter.slnx | 7 +- README.md | 6 +- ...V001__create_projects_and_tasks_tables.sql | 34 +++ .../migrations/V001__create_words_table.sql | 11 - src/CleanApiStarter.Api/Api.http | 62 ++++- .../Endpoints/V1/Projects.cs | 176 +++++++++++++ src/CleanApiStarter.Api/Endpoints/V1/Words.cs | 79 ------ .../Endpoints/V2/Projects.cs | 35 +++ src/CleanApiStarter.Api/Endpoints/V2/Words.cs | 35 --- src/CleanApiStarter.Api/GlobalUsings.cs | 6 +- src/CleanApiStarter.Api/Program.cs | 48 ++-- .../DependencyInjection.cs | 2 +- .../Features/Auth/AuthTokenDto.cs | 4 +- .../Features/Auth/CurrentUserDto.cs | 6 +- .../Features/Auth/GoogleSignInDto.cs | 2 +- .../Projects/CreateProjectDtoValidator.cs | 14 + .../Projects/CreateProjectTaskDtoValidator.cs | 19 ++ .../Features/Projects/IProjectRepository.cs | 35 +++ .../Features/Projects/IProjectService.cs | 32 +++ .../Features/Projects/ProjectDto.cs | 21 ++ .../Projects/ProjectOperationResults.cs | 15 ++ .../Features/Projects/ProjectService.cs | 248 ++++++++++++++++++ .../Features/Projects/ProjectTaskDto.cs | 40 +++ .../Projects/UpdateProjectTaskDtoValidator.cs | 24 ++ .../Features/Words/CreateWordDtoValidator.cs | 24 -- .../Features/Words/IWordRepository.cs | 10 - .../Features/Words/IWordService.cs | 10 - .../Features/Words/WordDto.cs | 18 -- .../Features/Words/WordService.cs | 111 -------- .../GlobalUsings.cs | 3 +- .../AspNetCoreDefaultServices.cs | 2 +- .../GlobalUsings.cs | 8 +- .../Entities/Project.cs | 18 ++ .../Entities/ProjectMember.cs | 12 + .../Entities/ProjectTask.cs | 22 ++ .../Entities/ProjectTaskStatus.cs | 8 + src/CleanApiStarter.Domain/Entities/Word.cs | 12 - .../DependencyInjection.cs | 2 +- .../GlobalUsings.cs | 3 +- .../Persistence/ApplicationDbContext.cs | 6 +- .../Configuration/ProjectConfiguration.cs | 31 +++ .../ProjectMemberConfiguration.cs | 30 +++ .../Configuration/ProjectTaskConfiguration.cs | 46 ++++ .../Configuration/WordConfiguration.cs | 40 --- .../Repositories/ProjectRepository.cs | 158 +++++++++++ .../Repositories/WordRepository.cs | 57 ---- ...eanApiStarter.Api.IntegerationTests.csproj | 15 ++ ...anApiStarter.Application.UnitTests.csproj} | 4 +- .../CleanApiStarter.Tests.csproj | 15 ++ 50 files changed, 1170 insertions(+), 474 deletions(-) create mode 100644 database/migrations/V001__create_projects_and_tasks_tables.sql delete mode 100644 database/migrations/V001__create_words_table.sql create mode 100644 src/CleanApiStarter.Api/Endpoints/V1/Projects.cs delete mode 100644 src/CleanApiStarter.Api/Endpoints/V1/Words.cs create mode 100644 src/CleanApiStarter.Api/Endpoints/V2/Projects.cs delete mode 100644 src/CleanApiStarter.Api/Endpoints/V2/Words.cs create mode 100644 src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs create mode 100644 src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs create mode 100644 src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs create mode 100644 src/CleanApiStarter.Application/Features/Projects/IProjectService.cs create mode 100644 src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs create mode 100644 src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs create mode 100644 src/CleanApiStarter.Application/Features/Projects/ProjectService.cs create mode 100644 src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs create mode 100644 src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs delete mode 100644 src/CleanApiStarter.Application/Features/Words/CreateWordDtoValidator.cs delete mode 100644 src/CleanApiStarter.Application/Features/Words/IWordRepository.cs delete mode 100644 src/CleanApiStarter.Application/Features/Words/IWordService.cs delete mode 100644 src/CleanApiStarter.Application/Features/Words/WordDto.cs delete mode 100644 src/CleanApiStarter.Application/Features/Words/WordService.cs create mode 100644 src/CleanApiStarter.Domain/Entities/Project.cs create mode 100644 src/CleanApiStarter.Domain/Entities/ProjectMember.cs create mode 100644 src/CleanApiStarter.Domain/Entities/ProjectTask.cs create mode 100644 src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs delete mode 100644 src/CleanApiStarter.Domain/Entities/Word.cs create mode 100644 src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs create mode 100644 src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs create mode 100644 src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs delete mode 100644 src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs create mode 100644 src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs delete mode 100644 src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs create mode 100644 tests/CleanApiStarter.Api.IntegerationTests/CleanApiStarter.Api.IntegerationTests.csproj rename tests/{CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj => CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj} (76%) create mode 100644 tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj diff --git a/AGENTS.md b/AGENTS.md index 9e6e59d..3addae4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,11 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. - Keep repository interfaces in `Application`, not `Domain`. - Keep database implementation details in `Infrastructure`. -- Keep domain models persistence-agnostic. For example, `Word.Synonyms` stays `List`, even though Postgres stores it as `jsonb`. +- Keep domain models persistence-agnostic. Persistence mapping details belong in Infrastructure EF Core configuration. +- Use `required` for non-null required scalar properties on DTOs and domain entities. Do not use `= string.Empty` only to satisfy nullable reference type warnings. +- Keep collection properties initialized with `= []`. +- Keep EF navigation properties as `= null!` when EF is responsible for materializing them. +- Use nullable types such as `string?` or `DateTime?` only for genuinely optional values. - Use EF Core through `ApplicationDbContext` for application persistence. Do not reintroduce Dapper for the default template data access path. - Keep `ApplicationDbContext` in `CleanApiStarter.Infrastructure/Persistence`, not in `Identity`. The context owns both application entities and Identity storage, so it is a persistence concern. - Keep EF Core fluent API entity maps in `CleanApiStarter.Infrastructure/Persistence/Configuration` using `IEntityTypeConfiguration`. Do not put entity mapping logic directly inside `ApplicationDbContext` unless there is a very small one-off reason. @@ -48,7 +52,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar ## API and Application Conventions -- Organize the `Application` project by feature. Put feature-specific contracts, DTOs, and application services under folders such as `Application/Features/Words` or `Application/Features/Auth`. +- Organize the `Application` project by feature. Put feature-specific contracts, DTOs, and application services under folders such as `Application/Features/Projects` or `Application/Features/Auth`. - Do not create generic `Application/Services` or `Application/Models` folders for feature-specific code. - Keep cross-cutting application abstractions under `Application/Common`, for example `Application/Common/Interfaces`. - Return single resources as their DTO object directly. @@ -74,11 +78,11 @@ This repository is a Clean Architecture API starter template named `CleanApiStar ## Database and Aspire - Use a single Postgres database: `postgres`. -- Do not create or reference a separate `wordlibrary` database. +- Do not create or reference a separate feature-specific database. - AppHost should expose the API connection from the `postgres` resource, and application settings should read it through `ConnectionStrings:Postgres`. - `docker-compose.yml` should use `POSTGRES_DB=postgres`. - Database schema scripts live in `database/migrations`, for example: - - `database/migrations/V001__create_words_table.sql` + - `database/migrations/V001__create_projects_and_tasks_tables.sql` - Do not add API startup database initialization such as `DbInitializer` or DbUp calls. Aspire/Docker init scripts own local schema creation. - EF Core is used for application data access and Identity storage, but schema creation still belongs to the SQL scripts in `database/migrations`. - Docker Postgres init scripts run only on first volume creation. If scripts need to replay, delete the old volume. @@ -107,7 +111,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Keep OpenTelemetry logs configured to include scopes, formatted messages, and parsed state values so structured message-template properties show up in Aspire. - Responses should include `X-Request-ID` with the current trace id, configured centrally in `CleanApiStarter.AspNetCore`. - Response compression should be configured centrally in `CleanApiStarter.AspNetCore` with Brotli and gzip providers. -- Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{WordId}` and `{WordCount}`. +- Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{ProjectId}`, `{TaskId}`, and `{UserId}`. - Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly when services need configuration values. - Do not add generic options registration helpers until the template has multiple real options sections that need them. - Do not create a broad `Shared` project. Keep cross-project settings in `CleanApiStarter.Configuration`. @@ -116,12 +120,12 @@ This repository is a Clean Architecture API starter template named `CleanApiStar ## API Style - Prefer Minimal APIs for this template. -- Keep `Program.cs` small by placing route groups in endpoint group classes under version folders such as `Api/Endpoints/V1/Words.cs`. +- Keep `Program.cs` small by placing route groups in endpoint group classes under version folders such as `Api/Endpoints/V1/Projects.cs`. - Endpoint group classes should implement `IEndpointGroup` from `CleanApiStarter.AspNetCore` and be mapped through `app.MapEndpoints(Assembly.GetExecutingAssembly());`. - Use built-in Minimal API mapping methods with explicit `.WithName(...)`; do not add custom `MapGet`/`MapPost` overloads that shadow framework methods. - API versions are selected with the optional `X-Api-Version` request header. Missing versions default to v1. - Endpoint groups should declare `MajorVersion` to match their folder, for example `V1` uses `1` and `V2` uses `2`. -- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetWordsV1` and `GetWordsV2`. +- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetProjectsV1` and `GetProjectsV2`. - Do not reintroduce MVC controllers unless the template intentionally changes direction. ## Authentication diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx index 7fa4829..f756d09 100644 --- a/CleanApiStarter.slnx +++ b/CleanApiStarter.slnx @@ -9,8 +9,9 @@ + - + @@ -25,6 +26,8 @@ - + + + diff --git a/README.md b/README.md index 005a459..3d51036 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ OpenAPI documents are generated per API version and shown in Scalar with a versi Collection endpoints return an `ArrayResult` envelope when they are not paginated. Paginated endpoints accept `limit` and `offset` query parameters and return `PaginatedResult` metadata with the items. Single-resource endpoints return the resource object directly. +The reference feature is project management. Users can create projects, list their projects, manage tasks inside projects, filter tasks by status, and complete tasks. Project membership controls task visibility, and project deletion is owner-only. + ## Authentication The API validates Google ID tokens and issues its own JWT access token. Google proves the user identity; ASP.NET Core Identity stores local users, roles, and external logins. @@ -60,7 +62,7 @@ Database scripts live in `database`. Aspire and Docker Compose copy `database/mi After the dashboard is running, send requests to the API through Scalar or another HTTP client. In the Aspire dashboard: -- Logs show structured application and framework log entries. The word use cases emit fields such as `WordId`, `SynonymCount`, `WordCount`, and `UpdateSucceeded`. +- Logs show structured application and framework log entries. The project and task use cases emit fields such as `ProjectId`, `TaskId`, `ProjectCount`, and `UserId`. - Request logs show method, path, response status code, duration, and `UserId` without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`. - Responses include an `X-Request-ID` header containing the current OpenTelemetry trace id, which can be used to correlate API responses with Aspire traces and logs. - Responses support Brotli and gzip compression when clients send an `Accept-Encoding` header. @@ -73,7 +75,7 @@ After the dashboard is running, send requests to the API through Scalar or anoth Cancellation tokens are explicit at application and infrastructure boundaries. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. API actions should accept a `CancellationToken` parameter and pass it through the application and repository calls. -Use structured logging message templates instead of interpolated log strings. Prefer stable property names such as `{WordId}` or `{WordCount}` so Aspire and OpenTelemetry can index and filter them. +Use structured logging message templates instead of interpolated log strings. Prefer stable property names such as `{ProjectId}` or `{TaskId}` so Aspire and OpenTelemetry can index and filter them. Configuration classes live in `CleanApiStarter.Configuration`. Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly where configuration values are needed. diff --git a/database/migrations/V001__create_projects_and_tasks_tables.sql b/database/migrations/V001__create_projects_and_tasks_tables.sql new file mode 100644 index 0000000..e2b7799 --- /dev/null +++ b/database/migrations/V001__create_projects_and_tasks_tables.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS projects ( + id UUID PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL, + owner_user_id VARCHAR(450) NOT NULL, + created_at TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS project_members ( + project_id UUID NOT NULL, + user_id VARCHAR(450) NOT NULL, + created_at TIMESTAMP NOT NULL, + CONSTRAINT pk_project_members PRIMARY KEY (project_id, user_id), + CONSTRAINT fk_project_members_projects_project_id + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS project_tasks ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL, + title VARCHAR(150) NOT NULL, + description TEXT NOT NULL, + status VARCHAR(20) NOT NULL, + due_date TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL, + CONSTRAINT fk_project_tasks_projects_project_id + FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_projects_owner_user_id ON projects(owner_user_id); +CREATE INDEX IF NOT EXISTS idx_project_members_user_id ON project_members(user_id); +CREATE INDEX IF NOT EXISTS idx_project_tasks_project_id ON project_tasks(project_id); +CREATE INDEX IF NOT EXISTS idx_project_tasks_project_id_status ON project_tasks(project_id, status); diff --git a/database/migrations/V001__create_words_table.sql b/database/migrations/V001__create_words_table.sql deleted file mode 100644 index 48ffe64..0000000 --- a/database/migrations/V001__create_words_table.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE TABLE IF NOT EXISTS words ( - id UUID PRIMARY KEY, - text VARCHAR(100) NOT NULL, - meaning TEXT NOT NULL, - synonyms JSONB NOT NULL, - usage_example TEXT NOT NULL, - created_at TIMESTAMP NOT NULL, - updated_at TIMESTAMP NULL -); - -CREATE INDEX IF NOT EXISTS idx_words_text ON words(text); diff --git a/src/CleanApiStarter.Api/Api.http b/src/CleanApiStarter.Api/Api.http index 66e410b..07f57a3 100644 --- a/src/CleanApiStarter.Api/Api.http +++ b/src/CleanApiStarter.Api/Api.http @@ -1,48 +1,88 @@ @CleanApiStarter_Api_HostAddress = https://localhost:7285 @AccessToken = paste-api-jwt-here -GET {{CleanApiStarter_Api_HostAddress}}/api/words?limit=20&offset=0 +POST {{CleanApiStarter_Api_HostAddress}}/api/auth/google +Content-Type: application/json + +{ + "idToken": "paste-google-id-token-here" +} + +### + +GET {{CleanApiStarter_Api_HostAddress}}/api/projects?limit=20&offset=0 Accept: application/json Authorization: Bearer {{AccessToken}} ### -POST {{CleanApiStarter_Api_HostAddress}}/api/auth/google +POST {{CleanApiStarter_Api_HostAddress}}/api/projects Content-Type: application/json +Authorization: Bearer {{AccessToken}} { - "idToken": "paste-google-id-token-here" + "name": "Clean API Starter", + "description": "Template implementation work." } ### -GET {{CleanApiStarter_Api_HostAddress}}/api/auth/me +GET {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000 +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### + +POST {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks +Content-Type: application/json +Authorization: Bearer {{AccessToken}} + +{ + "title": "Add observability defaults", + "description": "Configure logs, traces, metrics, and request ids.", + "dueDate": "2026-12-31T00:00:00Z" +} + +### + +GET {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks?limit=20&offset=0&status=Todo Accept: application/json Authorization: Bearer {{AccessToken}} ### -POST {{CleanApiStarter_Api_HostAddress}}/api/words +PUT {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks/00000000-0000-0000-0000-000000000000 Content-Type: application/json Authorization: Bearer {{AccessToken}} { - "text": "observable", - "meaning": "Able to be noticed, measured, or monitored.", - "synonyms": ["visible", "measurable"], - "usageExample": "OpenTelemetry makes API behavior observable." + "title": "Add observability defaults", + "description": "Configure logs, traces, metrics, and request ids.", + "status": "InProgress", + "dueDate": "2026-12-31T00:00:00Z" } ### -GET {{CleanApiStarter_Api_HostAddress}}/api/words?limit=20&offset=0 +POST {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000/tasks/00000000-0000-0000-0000-000000000000/complete +Authorization: Bearer {{AccessToken}} + +### + +GET {{CleanApiStarter_Api_HostAddress}}/api/projects?limit=20&offset=0 Accept: application/json X-Api-Version: 2.0 Authorization: Bearer {{AccessToken}} ### -DELETE {{CleanApiStarter_Api_HostAddress}}/api/words/00000000-0000-0000-0000-000000000000 +DELETE {{CleanApiStarter_Api_HostAddress}}/api/projects/00000000-0000-0000-0000-000000000000 +Authorization: Bearer {{AccessToken}} + +### + +GET {{CleanApiStarter_Api_HostAddress}}/api/auth/me +Accept: application/json Authorization: Bearer {{AccessToken}} ### diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs b/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs new file mode 100644 index 0000000..b5c74a0 --- /dev/null +++ b/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs @@ -0,0 +1,176 @@ +namespace CleanApiStarter.Api.Endpoints.V1; + +public sealed class Projects : IEndpointGroup +{ + public static int MajorVersion => 1; + + public static string RoutePrefix => "/api/projects"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapPost("/", CreateProject) + .WithName("CreateProjectV1"); + + groupBuilder.MapGet("/", GetProjects) + .WithName("GetProjectsV1"); + + groupBuilder.MapGet("/{id:guid}", GetProject) + .WithName("GetProjectV1"); + + groupBuilder.MapDelete("/{id:guid}", DeleteProject) + .WithName("DeleteProjectV1"); + + groupBuilder.MapPost("/{projectId:guid}/tasks", CreateTask) + .WithName("CreateProjectTaskV1"); + + groupBuilder.MapGet("/{projectId:guid}/tasks", GetTasks) + .WithName("GetProjectTasksV1"); + + groupBuilder.MapGet("/{projectId:guid}/tasks/{taskId:guid}", GetTask) + .WithName("GetProjectTaskV1"); + + groupBuilder.MapPut("/{projectId:guid}/tasks/{taskId:guid}", UpdateTask) + .WithName("UpdateProjectTaskV1"); + + groupBuilder.MapPost("/{projectId:guid}/tasks/{taskId:guid}/complete", CompleteTask) + .WithName("CompleteProjectTaskV1"); + + groupBuilder.MapDelete("/{projectId:guid}/tasks/{taskId:guid}", DeleteTask) + .WithName("DeleteProjectTaskV1"); + } + + private static async Task CreateProject( + CreateProjectDto projectDto, + IProjectService projectService, + CancellationToken cancellationToken) + { + Guid id = await projectService.CreateProjectAsync(projectDto, cancellationToken); + + return Results.Created($"/api/projects/{id}", id); + } + + private static async Task GetProjects( + [AsParameters] PaginatedQuery query, + IProjectService projectService, + CancellationToken cancellationToken) + { + PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); + + return Results.Ok(projects); + } + + private static async Task GetProject( + Guid id, + IProjectService projectService, + CancellationToken cancellationToken) + { + ProjectDto? project = await projectService.GetProjectAsync(id, cancellationToken); + + return project == null ? Results.NotFound() : Results.Ok(project); + } + + private static async Task DeleteProject( + Guid id, + IProjectService projectService, + CancellationToken cancellationToken) + { + DeleteProjectResult result = await projectService.DeleteProjectAsync(id, cancellationToken); + + return result switch + { + DeleteProjectResult.Deleted => Results.NoContent(), + DeleteProjectResult.Forbidden => Results.Forbid(), + _ => Results.NotFound() + }; + } + + private static async Task CreateTask( + Guid projectId, + CreateProjectTaskDto taskDto, + IProjectService projectService, + CancellationToken cancellationToken) + { + Guid? taskId = await projectService.CreateTaskAsync(projectId, taskDto, cancellationToken); + + return taskId == null + ? Results.NotFound() + : Results.Created($"/api/projects/{projectId}/tasks/{taskId}", taskId); + } + + private static async Task GetTasks( + Guid projectId, + [FromQuery] ProjectTaskStatus? status, + [AsParameters] PaginatedQuery query, + IProjectService projectService, + CancellationToken cancellationToken) + { + PaginatedResult? tasks = await projectService.GetTasksAsync( + projectId, + status, + query, + cancellationToken); + + return tasks == null ? Results.NotFound() : Results.Ok(tasks); + } + + private static async Task GetTask( + Guid projectId, + Guid taskId, + IProjectService projectService, + CancellationToken cancellationToken) + { + ProjectTaskDto? task = await projectService.GetTaskAsync(projectId, taskId, cancellationToken); + + return task == null ? Results.NotFound() : Results.Ok(task); + } + + private static async Task UpdateTask( + Guid projectId, + Guid taskId, + UpdateProjectTaskDto taskDto, + IProjectService projectService, + CancellationToken cancellationToken) + { + ProjectTaskMutationResult result = await projectService.UpdateTaskAsync( + projectId, + taskId, + taskDto, + cancellationToken); + + return result == ProjectTaskMutationResult.Success + ? Results.NoContent() + : Results.NotFound(); + } + + private static async Task CompleteTask( + Guid projectId, + Guid taskId, + IProjectService projectService, + CancellationToken cancellationToken) + { + ProjectTaskMutationResult result = await projectService.CompleteTaskAsync(projectId, taskId, cancellationToken); + + return result switch + { + ProjectTaskMutationResult.Success => Results.NoContent(), + ProjectTaskMutationResult.AlreadyCompleted => Results.Conflict(new + { + detail = "Task is already completed." + }), + _ => Results.NotFound() + }; + } + + private static async Task DeleteTask( + Guid projectId, + Guid taskId, + IProjectService projectService, + CancellationToken cancellationToken) + { + bool deleted = await projectService.DeleteTaskAsync(projectId, taskId, cancellationToken); + + return deleted ? Results.NoContent() : Results.NotFound(); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Words.cs b/src/CleanApiStarter.Api/Endpoints/V1/Words.cs deleted file mode 100644 index 6396eb3..0000000 --- a/src/CleanApiStarter.Api/Endpoints/V1/Words.cs +++ /dev/null @@ -1,79 +0,0 @@ -namespace CleanApiStarter.Api.Endpoints.V1; - -public sealed class Words : IEndpointGroup -{ - public static int MajorVersion => 1; - - public static string RoutePrefix => "/api/words"; - - public static void Map(RouteGroupBuilder groupBuilder) - { - groupBuilder.RequireAuthorization(); - - groupBuilder.MapPost("/", CreateWord) - .WithName("CreateWordV1"); - - groupBuilder.MapGet("/", GetWords) - .WithName("GetWordsV1"); - - groupBuilder.MapGet("/{id:guid}", GetWord) - .WithName("GetWordV1"); - - groupBuilder.MapPut("/{id:guid}", UpdateWord) - .WithName("UpdateWordV1"); - - groupBuilder.MapDelete("/{id:guid}", DeleteWord) - .WithName("DeleteWordV1"); - } - - private static async Task CreateWord( - CreateWordDto wordDto, - IWordService wordService, - CancellationToken cancellationToken) - { - Guid id = await wordService.AddWordAsync(wordDto, cancellationToken); - - return Results.Created($"/api/words/{id}", id); - } - - private static async Task GetWord( - Guid id, - IWordService wordService, - CancellationToken cancellationToken) - { - WordDto? word = await wordService.GetWordByIdAsync(id, cancellationToken); - - return word == null ? Results.NotFound() : Results.Ok(word); - } - - private static async Task GetWords( - [AsParameters] PaginatedQuery query, - IWordService wordService, - CancellationToken cancellationToken) - { - PaginatedResult words = await wordService.GetWordsAsync(query, cancellationToken); - - return Results.Ok(words); - } - - private static async Task UpdateWord( - Guid id, - CreateWordDto wordDto, - IWordService wordService, - CancellationToken cancellationToken) - { - bool success = await wordService.UpdateWordAsync(id, wordDto, cancellationToken); - - return success ? Results.NoContent() : Results.NotFound(); - } - - private static async Task DeleteWord( - Guid id, - IWordService wordService, - CancellationToken cancellationToken) - { - bool success = await wordService.DeleteWordAsync(id, cancellationToken); - - return success ? Results.NoContent() : Results.NotFound(); - } -} \ No newline at end of file diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs b/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs new file mode 100644 index 0000000..f4e8e85 --- /dev/null +++ b/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Api.Endpoints.V2; + +public sealed class Projects : IEndpointGroup +{ + public static int MajorVersion => 2; + + public static string RoutePrefix => "/api/projects"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + groupBuilder.MapGet("/", GetProjects) + .WithName("GetProjectsV2"); + } + + private static async Task GetProjects( + [AsParameters] PaginatedQuery query, + IProjectService projectService, + CancellationToken cancellationToken) + { + PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); + + return Results.Ok(new + { + ApiVersion = "2.0", + projects.Items, + projects.Limit, + projects.Offset, + projects.TotalCount, + projects.HasPreviousPage, + projects.HasNextPage + }); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Words.cs b/src/CleanApiStarter.Api/Endpoints/V2/Words.cs deleted file mode 100644 index 6c9cbb3..0000000 --- a/src/CleanApiStarter.Api/Endpoints/V2/Words.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace CleanApiStarter.Api.Endpoints.V2; - -public sealed class Words : IEndpointGroup -{ - public static int MajorVersion => 2; - - public static string RoutePrefix => "/api/words"; - - public static void Map(RouteGroupBuilder groupBuilder) - { - groupBuilder.RequireAuthorization(); - - groupBuilder.MapGet("/", GetWords) - .WithName("GetWordsV2"); - } - - private static async Task GetWords( - [AsParameters] PaginatedQuery query, - IWordService wordService, - CancellationToken cancellationToken) - { - PaginatedResult words = await wordService.GetWordsAsync(query, cancellationToken); - - return Results.Ok(new - { - ApiVersion = "2.0", - words.Items, - words.Limit, - words.Offset, - words.TotalCount, - words.HasPreviousPage, - words.HasNextPage - }); - } -} \ No newline at end of file diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs index 72e709e..db4fb8f 100644 --- a/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -12,12 +12,14 @@ global using CleanApiStarter.Application.Common.Interfaces; global using CleanApiStarter.Application.Common.Models; global using CleanApiStarter.Application.Features.Auth; -global using CleanApiStarter.Application.Features.Words; +global using CleanApiStarter.Application.Features.Projects; global using CleanApiStarter.AspNetCore; global using CleanApiStarter.Configuration; +global using CleanApiStarter.Domain.Entities; global using CleanApiStarter.Infrastructure; global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Routing; -global using Scalar.AspNetCore; +global using Scalar.AspNetCore; \ No newline at end of file diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs index ebb87d3..5e40fa7 100644 --- a/src/CleanApiStarter.Api/Program.cs +++ b/src/CleanApiStarter.Api/Program.cs @@ -1,33 +1,33 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.AddAspNetCoreDefaults(); - -builder.Services.AddAppSettings(builder.Configuration); -builder.Services.AddApplication(); -builder.Services.AddInfrastructure(); +builder.AddAspNetCoreDefaults(); + +builder.Services.AddAppSettings(builder.Configuration); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(); builder.Services.AddScoped(); WebApplication app = builder.Build(); // Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi() - .WithDocumentPerVersion(); - - app.MapScalarApiReference(options => - { - IReadOnlyList descriptions = app.DescribeApiVersions(); - - for (int index = 0; index < descriptions.Count; index++) - { - ApiVersionDescription description = descriptions[index]; - bool isDefault = index == descriptions.Count - 1; - - options.AddDocument(description.GroupName, description.GroupName, isDefault: isDefault); - } - }); -} +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi() + .WithDocumentPerVersion(); + + app.MapScalarApiReference(options => + { + IReadOnlyList descriptions = app.DescribeApiVersions(); + + for (int index = 0; index < descriptions.Count; index++) + { + ApiVersionDescription description = descriptions[index]; + bool isDefault = index == descriptions.Count - 1; + + options.AddDocument(description.GroupName, description.GroupName, isDefault: isDefault); + } + }); +} app.UseAspNetCoreDefaults(); app.UseHttpsRedirection(); @@ -35,4 +35,4 @@ app.MapEndpoints(Assembly.GetExecutingAssembly()); app.MapDefaultEndpoints(); -app.Run(); +app.Run(); \ No newline at end of file diff --git a/src/CleanApiStarter.Application/DependencyInjection.cs b/src/CleanApiStarter.Application/DependencyInjection.cs index 055467a..54e6f72 100644 --- a/src/CleanApiStarter.Application/DependencyInjection.cs +++ b/src/CleanApiStarter.Application/DependencyInjection.cs @@ -5,7 +5,7 @@ public static class DependencyInjection public static IServiceCollection AddApplication(this IServiceCollection services) { services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); - services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs b/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs index 7f32c89..95156bf 100644 --- a/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs +++ b/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs @@ -2,7 +2,7 @@ namespace CleanApiStarter.Application.Features.Auth; public sealed class AuthTokenDto { - public string AccessToken { get; init; } = string.Empty; + public required string AccessToken { get; init; } - public DateTimeOffset ExpiresAt { get; init; } + public required DateTimeOffset ExpiresAt { get; init; } } \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs b/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs index f2618c7..e52f126 100644 --- a/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs +++ b/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs @@ -2,11 +2,11 @@ namespace CleanApiStarter.Application.Features.Auth; public sealed class CurrentUserDto { - public string UserId { get; init; } = string.Empty; + public required string UserId { get; init; } - public string Email { get; init; } = string.Empty; + public required string Email { get; init; } - public string Name { get; init; } = string.Empty; + public required string Name { get; init; } public IReadOnlyCollection Roles { get; init; } = []; } \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs index 3eb4663..c5d9da9 100644 --- a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs +++ b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs @@ -2,5 +2,5 @@ namespace CleanApiStarter.Application.Features.Auth; public sealed class GoogleSignInDto { - public string IdToken { get; init; } = string.Empty; + public required string IdToken { get; init; } } \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs new file mode 100644 index 0000000..aee321f --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs @@ -0,0 +1,14 @@ +namespace CleanApiStarter.Application.Features.Projects; + +public sealed class CreateProjectDtoValidator : AbstractValidator +{ + public CreateProjectDtoValidator() + { + RuleFor(project => project.Name) + .NotEmpty() + .MaximumLength(100); + + RuleFor(project => project.Description) + .MaximumLength(2_000); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs new file mode 100644 index 0000000..91bac41 --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs @@ -0,0 +1,19 @@ +namespace CleanApiStarter.Application.Features.Projects; + +public sealed class CreateProjectTaskDtoValidator : AbstractValidator +{ + public CreateProjectTaskDtoValidator() + { + RuleFor(task => task.Title) + .NotEmpty() + .MaximumLength(150); + + RuleFor(task => task.Description) + .MaximumLength(4_000); + + RuleFor(task => task.DueDate) + .Must(dueDate => !dueDate.HasValue || dueDate.Value > DateTime.UtcNow) + .When(task => task.DueDate.HasValue) + .WithMessage("Due date must be in the future."); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs b/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs new file mode 100644 index 0000000..d0b627b --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Application.Features.Projects; + +public interface IProjectRepository +{ + Task AddProjectAsync(Project project, CancellationToken cancellationToken); + + Task GetProjectAsync(Guid id, string userId, CancellationToken cancellationToken); + + Task> GetProjectsAsync(string userId, PaginatedQuery query, CancellationToken cancellationToken); + + Task IsProjectMemberAsync(Guid projectId, string userId, CancellationToken cancellationToken); + + Task IsProjectOwnerAsync(Guid projectId, string userId, CancellationToken cancellationToken); + + Task ProjectExistsAsync(Guid projectId, CancellationToken cancellationToken); + + Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken); + + Task AddTaskAsync(ProjectTask task, CancellationToken cancellationToken); + + Task GetTaskAsync(Guid projectId, Guid taskId, string userId, CancellationToken cancellationToken); + + Task GetTaskForUpdateAsync(Guid projectId, Guid taskId, string userId, CancellationToken cancellationToken); + + Task> GetTasksAsync( + Guid projectId, + string userId, + ProjectTaskStatus? status, + PaginatedQuery query, + CancellationToken cancellationToken); + + Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); + + Task SaveChangesAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs b/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs new file mode 100644 index 0000000..cc6f1c9 --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs @@ -0,0 +1,32 @@ +namespace CleanApiStarter.Application.Features.Projects; + +public interface IProjectService +{ + Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken); + + Task GetProjectAsync(Guid id, CancellationToken cancellationToken); + + Task> GetProjectsAsync(PaginatedQuery query, CancellationToken cancellationToken); + + Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken); + + Task CreateTaskAsync(Guid projectId, CreateProjectTaskDto taskDto, CancellationToken cancellationToken); + + Task GetTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); + + Task?> GetTasksAsync( + Guid projectId, + ProjectTaskStatus? status, + PaginatedQuery query, + CancellationToken cancellationToken); + + Task UpdateTaskAsync( + Guid projectId, + Guid taskId, + UpdateProjectTaskDto taskDto, + CancellationToken cancellationToken); + + Task CompleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); + + Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs new file mode 100644 index 0000000..640b8bd --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs @@ -0,0 +1,21 @@ +namespace CleanApiStarter.Application.Features.Projects; + +public sealed class ProjectDto +{ + public required Guid Id { get; init; } + + public required string Name { get; init; } + + public required string Description { get; init; } + + public required string OwnerUserId { get; init; } + + public required DateTime CreatedAt { get; init; } +} + +public sealed class CreateProjectDto +{ + public required string Name { get; init; } + + public required string Description { get; init; } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs new file mode 100644 index 0000000..d441eae --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs @@ -0,0 +1,15 @@ +namespace CleanApiStarter.Application.Features.Projects; + +public enum DeleteProjectResult +{ + Deleted, + NotFound, + Forbidden +} + +public enum ProjectTaskMutationResult +{ + Success, + NotFound, + AlreadyCompleted +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs new file mode 100644 index 0000000..ebcc417 --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs @@ -0,0 +1,248 @@ +namespace CleanApiStarter.Application.Features.Projects; + +public sealed class ProjectService( + IProjectRepository projectRepository, + IUser currentUser, + ILogger logger) : IProjectService +{ + public async Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + DateTime now = DateTime.UtcNow; + Guid projectId = Guid.NewGuid(); + Project project = new() + { + Id = projectId, + Name = projectDto.Name, + Description = projectDto.Description, + OwnerUserId = userId, + CreatedAt = now, + Members = + [ + new ProjectMember + { + ProjectId = projectId, + UserId = userId, + CreatedAt = now + } + ] + }; + + Guid createdProjectId = await projectRepository.AddProjectAsync(project, cancellationToken); + + logger.LogInformation("Created project {ProjectId} for user {UserId}", createdProjectId, userId); + + return createdProjectId; + } + + public async Task GetProjectAsync(Guid id, CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + Project? project = await projectRepository.GetProjectAsync(id, userId, cancellationToken); + + return project == null ? null : MapProject(project); + } + + public async Task> GetProjectsAsync( + PaginatedQuery query, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + PaginatedResult projects = await projectRepository.GetProjectsAsync(userId, query, cancellationToken); + + logger.LogInformation( + "Retrieved {ProjectCount} projects for user {UserId} with limit {Limit} and offset {Offset}", + projects.Items.Count, + userId, + projects.Limit, + projects.Offset); + + return projects.Map(MapProject); + } + + public async Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + bool isMember = await projectRepository.IsProjectMemberAsync(id, userId, cancellationToken); + + if (!isMember) + { + return DeleteProjectResult.NotFound; + } + + bool isOwner = await projectRepository.IsProjectOwnerAsync(id, userId, cancellationToken); + + if (!isOwner) + { + return DeleteProjectResult.Forbidden; + } + + bool deleted = await projectRepository.DeleteProjectAsync(id, cancellationToken); + + return deleted ? DeleteProjectResult.Deleted : DeleteProjectResult.NotFound; + } + + public async Task CreateTaskAsync( + Guid projectId, + CreateProjectTaskDto taskDto, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); + + if (!isMember) + { + return null; + } + + ProjectTask task = new() + { + Id = Guid.NewGuid(), + ProjectId = projectId, + Title = taskDto.Title, + Description = taskDto.Description, + Status = ProjectTaskStatus.Todo, + DueDate = taskDto.DueDate, + CreatedAt = DateTime.UtcNow + }; + + Guid taskId = await projectRepository.AddTaskAsync(task, cancellationToken); + + logger.LogInformation("Created task {TaskId} in project {ProjectId}", taskId, projectId); + + return taskId; + } + + public async Task GetTaskAsync( + Guid projectId, + Guid taskId, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + ProjectTask? task = await projectRepository.GetTaskAsync(projectId, taskId, userId, cancellationToken); + + return task == null ? null : MapTask(task); + } + + public async Task?> GetTasksAsync( + Guid projectId, + ProjectTaskStatus? status, + PaginatedQuery query, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); + + if (!isMember) + { + return null; + } + + PaginatedResult tasks = await projectRepository.GetTasksAsync( + projectId, + userId, + status, + query, + cancellationToken); + + return tasks.Map(MapTask); + } + + public async Task UpdateTaskAsync( + Guid projectId, + Guid taskId, + UpdateProjectTaskDto taskDto, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); + + if (task == null) + { + return ProjectTaskMutationResult.NotFound; + } + + task.Title = taskDto.Title; + task.Description = taskDto.Description; + task.DueDate = taskDto.DueDate; + task.Status = taskDto.Status; + task.CompletedAt = taskDto.Status == ProjectTaskStatus.Done + ? task.CompletedAt ?? DateTime.UtcNow + : null; + + await projectRepository.SaveChangesAsync(cancellationToken); + + return ProjectTaskMutationResult.Success; + } + + public async Task CompleteTaskAsync( + Guid projectId, + Guid taskId, + CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); + + if (task == null) + { + return ProjectTaskMutationResult.NotFound; + } + + if (task.Status == ProjectTaskStatus.Done) + { + return ProjectTaskMutationResult.AlreadyCompleted; + } + + task.Status = ProjectTaskStatus.Done; + task.CompletedAt = DateTime.UtcNow; + + await projectRepository.SaveChangesAsync(cancellationToken); + + return ProjectTaskMutationResult.Success; + } + + public async Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken) + { + string userId = GetCurrentUserId(); + bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); + + if (!isMember) + { + return false; + } + + return await projectRepository.DeleteTaskAsync(projectId, taskId, cancellationToken); + } + + private string GetCurrentUserId() + { + return currentUser.Id ?? throw new UnauthorizedAccessException(); + } + + private static ProjectDto MapProject(Project project) + { + return new ProjectDto + { + Id = project.Id, + Name = project.Name, + Description = project.Description, + OwnerUserId = project.OwnerUserId, + CreatedAt = project.CreatedAt + }; + } + + private static ProjectTaskDto MapTask(ProjectTask task) + { + return new ProjectTaskDto + { + Id = task.Id, + ProjectId = task.ProjectId, + Title = task.Title, + Description = task.Description, + Status = task.Status, + DueDate = task.DueDate, + CreatedAt = task.CreatedAt, + CompletedAt = task.CompletedAt + }; + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs new file mode 100644 index 0000000..bd1803b --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs @@ -0,0 +1,40 @@ +namespace CleanApiStarter.Application.Features.Projects; + +public sealed class ProjectTaskDto +{ + public required Guid Id { get; init; } + + public required Guid ProjectId { get; init; } + + public required string Title { get; init; } + + public required string Description { get; init; } + + public required ProjectTaskStatus Status { get; init; } + + public DateTime? DueDate { get; init; } + + public required DateTime CreatedAt { get; init; } + + public DateTime? CompletedAt { get; init; } +} + +public sealed class CreateProjectTaskDto +{ + public required string Title { get; init; } + + public required string Description { get; init; } + + public DateTime? DueDate { get; init; } +} + +public sealed class UpdateProjectTaskDto +{ + public required string Title { get; init; } + + public required string Description { get; init; } + + public required ProjectTaskStatus Status { get; init; } + + public DateTime? DueDate { get; init; } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs new file mode 100644 index 0000000..6b00b52 --- /dev/null +++ b/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs @@ -0,0 +1,24 @@ +namespace CleanApiStarter.Application.Features.Projects; + +public sealed class UpdateProjectTaskDtoValidator : AbstractValidator +{ + public UpdateProjectTaskDtoValidator() + { + RuleFor(task => task.Title) + .NotEmpty() + .MaximumLength(150); + + RuleFor(task => task.Description) + .MaximumLength(4_000); + + RuleFor(task => task.Status) + .IsInEnum(); + + RuleFor(task => task.DueDate) + .Must((task, dueDate) => !dueDate.HasValue + || task.Status == ProjectTaskStatus.Done + || dueDate.Value > DateTime.UtcNow) + .When(task => task.DueDate.HasValue && task.Status != ProjectTaskStatus.Done) + .WithMessage("Due date must be in the future."); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Words/CreateWordDtoValidator.cs b/src/CleanApiStarter.Application/Features/Words/CreateWordDtoValidator.cs deleted file mode 100644 index 5e437a2..0000000 --- a/src/CleanApiStarter.Application/Features/Words/CreateWordDtoValidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace CleanApiStarter.Application.Features.Words; - -public sealed class CreateWordDtoValidator : AbstractValidator -{ - public CreateWordDtoValidator() - { - RuleFor(word => word.Text) - .NotEmpty() - .MaximumLength(100); - - RuleFor(word => word.Meaning) - .NotEmpty(); - - RuleFor(word => word.Synonyms) - .NotNull(); - - RuleForEach(word => word.Synonyms) - .NotEmpty() - .MaximumLength(100); - - RuleFor(word => word.UsageExample) - .NotEmpty(); - } -} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Words/IWordRepository.cs b/src/CleanApiStarter.Application/Features/Words/IWordRepository.cs deleted file mode 100644 index 1f9bf33..0000000 --- a/src/CleanApiStarter.Application/Features/Words/IWordRepository.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CleanApiStarter.Application.Features.Words; - -public interface IWordRepository -{ - Task AddWordAsync(Word word, CancellationToken cancellationToken); - Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); - Task> GetWordsAsync(PaginatedQuery query, CancellationToken cancellationToken); - Task UpdateWordAsync(Word word, CancellationToken cancellationToken); - Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Words/IWordService.cs b/src/CleanApiStarter.Application/Features/Words/IWordService.cs deleted file mode 100644 index 2e19a71..0000000 --- a/src/CleanApiStarter.Application/Features/Words/IWordService.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CleanApiStarter.Application.Features.Words; - -public interface IWordService -{ - Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken); - Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken); - Task> GetWordsAsync(PaginatedQuery query, CancellationToken cancellationToken); - Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken); - Task DeleteWordAsync(Guid id, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Words/WordDto.cs b/src/CleanApiStarter.Application/Features/Words/WordDto.cs deleted file mode 100644 index ef18d55..0000000 --- a/src/CleanApiStarter.Application/Features/Words/WordDto.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace CleanApiStarter.Application.Features.Words; - -public class WordDto -{ - public Guid Id { get; set; } - public string Text { get; set; } = string.Empty; - public string Meaning { get; set; } = string.Empty; - public List Synonyms { get; set; } = new(); - public string UsageExample { get; set; } = string.Empty; -} - -public class CreateWordDto -{ - public string Text { get; set; } = string.Empty; - public string Meaning { get; set; } = string.Empty; - public List Synonyms { get; set; } = new(); - public string UsageExample { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/Features/Words/WordService.cs b/src/CleanApiStarter.Application/Features/Words/WordService.cs deleted file mode 100644 index d05a729..0000000 --- a/src/CleanApiStarter.Application/Features/Words/WordService.cs +++ /dev/null @@ -1,111 +0,0 @@ -namespace CleanApiStarter.Application.Features.Words; - -public class WordService(IWordRepository wordRepository, ILogger logger) : IWordService -{ - public async Task AddWordAsync(CreateWordDto wordDto, CancellationToken cancellationToken) - { - Word word = new() - { - Id = Guid.NewGuid(), - Text = wordDto.Text, - Meaning = wordDto.Meaning, - Synonyms = wordDto.Synonyms, - UsageExample = wordDto.UsageExample, - CreatedAt = DateTime.UtcNow - }; - - logger.LogInformation( - "Creating word {WordId} with {SynonymCount} synonyms", - word.Id, - word.Synonyms.Count); - - Guid wordId = await wordRepository.AddWordAsync(word, cancellationToken); - - logger.LogInformation("Created word {WordId}", wordId); - - return wordId; - } - - public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken) - { - Word? word = await wordRepository.GetWordByIdAsync(id, cancellationToken); - - if (word == null) - { - logger.LogWarning("Word {WordId} was not found", id); - return null; - } - - logger.LogInformation("Retrieved word {WordId}", id); - - return MapToDto(word); - } - - public async Task> GetWordsAsync( - PaginatedQuery query, - CancellationToken cancellationToken) - { - PaginatedResult words = await wordRepository.GetWordsAsync(query, cancellationToken); - PaginatedResult wordDtos = words.Map(MapToDto); - - logger.LogInformation( - "Retrieved {WordCount} words with limit {Limit} and offset {Offset}", - wordDtos.Items.Count, - wordDtos.Limit, - wordDtos.Offset); - - return wordDtos; - } - - public async Task UpdateWordAsync(Guid id, CreateWordDto wordDto, CancellationToken cancellationToken) - { - Word? existingWord = await wordRepository.GetWordByIdAsync(id, cancellationToken); - if (existingWord == null) - { - logger.LogWarning("Word {WordId} could not be updated because it was not found", id); - return false; - } - - existingWord.Text = wordDto.Text; - existingWord.Meaning = wordDto.Meaning; - existingWord.Synonyms = wordDto.Synonyms; - existingWord.UsageExample = wordDto.UsageExample; - existingWord.UpdatedAt = DateTime.UtcNow; - - bool updated = await wordRepository.UpdateWordAsync(existingWord, cancellationToken); - - logger.LogInformation( - "Updated word {WordId}; success: {UpdateSucceeded}", - id, - updated); - - return updated; - } - - public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken) - { - bool deleted = await wordRepository.DeleteWordAsync(id, cancellationToken); - - if (!deleted) - { - logger.LogWarning("Word {WordId} could not be deleted because it was not found", id); - return false; - } - - logger.LogInformation("Deleted word {WordId}", id); - - return true; - } - - private static WordDto MapToDto(Word word) - { - return new WordDto - { - Id = word.Id, - Text = word.Text, - Meaning = word.Meaning, - Synonyms = word.Synonyms, - UsageExample = word.UsageExample - }; - } -} \ No newline at end of file diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/src/CleanApiStarter.Application/GlobalUsings.cs index 1c481e2..72d832e 100644 --- a/src/CleanApiStarter.Application/GlobalUsings.cs +++ b/src/CleanApiStarter.Application/GlobalUsings.cs @@ -1,7 +1,8 @@ global using System.Security.Claims; +global using CleanApiStarter.Application.Common.Interfaces; global using CleanApiStarter.Application.Common.Models; -global using CleanApiStarter.Application.Features.Words; +global using CleanApiStarter.Application.Features.Projects; global using CleanApiStarter.Domain.Entities; global using FluentValidation; diff --git a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs index 2e7e5bc..a70143d 100644 --- a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs +++ b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs @@ -113,4 +113,4 @@ private static void AddServiceDiscoveryDefaults(this IHostApplicationBuilder bui http.AddServiceDiscovery(); }); } -} +} \ No newline at end of file diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs index 1e95eb5..a0aabf7 100644 --- a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs +++ b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs @@ -5,9 +5,9 @@ global using System.Security.Claims; global using System.Text; -global using Asp.Versioning; -global using Asp.Versioning.ApiExplorer; -global using Asp.Versioning.Builder; +global using Asp.Versioning; +global using Asp.Versioning.ApiExplorer; +global using Asp.Versioning.Builder; global using CleanApiStarter.Configuration; @@ -33,4 +33,4 @@ global using OpenTelemetry.Logs; global using OpenTelemetry.Metrics; -global using OpenTelemetry.Trace; +global using OpenTelemetry.Trace; \ No newline at end of file diff --git a/src/CleanApiStarter.Domain/Entities/Project.cs b/src/CleanApiStarter.Domain/Entities/Project.cs new file mode 100644 index 0000000..a9073d7 --- /dev/null +++ b/src/CleanApiStarter.Domain/Entities/Project.cs @@ -0,0 +1,18 @@ +namespace CleanApiStarter.Domain.Entities; + +public sealed class Project +{ + public required Guid Id { get; init; } + + public required string Name { get; init; } + + public required string Description { get; init; } + + public required string OwnerUserId { get; init; } + + public required DateTime CreatedAt { get; init; } + + public List Members { get; init; } = []; + + public List Tasks { get; init; } = []; +} \ No newline at end of file diff --git a/src/CleanApiStarter.Domain/Entities/ProjectMember.cs b/src/CleanApiStarter.Domain/Entities/ProjectMember.cs new file mode 100644 index 0000000..d9ec077 --- /dev/null +++ b/src/CleanApiStarter.Domain/Entities/ProjectMember.cs @@ -0,0 +1,12 @@ +namespace CleanApiStarter.Domain.Entities; + +public sealed class ProjectMember +{ + public required Guid ProjectId { get; init; } + + public required string UserId { get; init; } + + public required DateTime CreatedAt { get; init; } + + public Project Project { get; init; } = null!; +} \ No newline at end of file diff --git a/src/CleanApiStarter.Domain/Entities/ProjectTask.cs b/src/CleanApiStarter.Domain/Entities/ProjectTask.cs new file mode 100644 index 0000000..ae8cc3e --- /dev/null +++ b/src/CleanApiStarter.Domain/Entities/ProjectTask.cs @@ -0,0 +1,22 @@ +namespace CleanApiStarter.Domain.Entities; + +public sealed class ProjectTask +{ + public required Guid Id { get; init; } + + public required Guid ProjectId { get; init; } + + public required string Title { get; set; } + + public required string Description { get; set; } + + public ProjectTaskStatus Status { get; set; } + + public DateTime? DueDate { get; set; } + + public required DateTime CreatedAt { get; init; } + + public DateTime? CompletedAt { get; set; } + + public Project Project { get; init; } = null!; +} \ No newline at end of file diff --git a/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs b/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs new file mode 100644 index 0000000..8a1a987 --- /dev/null +++ b/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Domain.Entities; + +public enum ProjectTaskStatus +{ + Todo = 0, + InProgress = 1, + Done = 2 +} \ No newline at end of file diff --git a/src/CleanApiStarter.Domain/Entities/Word.cs b/src/CleanApiStarter.Domain/Entities/Word.cs deleted file mode 100644 index 177c398..0000000 --- a/src/CleanApiStarter.Domain/Entities/Word.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CleanApiStarter.Domain.Entities; - -public class Word -{ - public Guid Id { get; init; } - public string Text { get; set; } = string.Empty; - public string Meaning { get; set; } = string.Empty; - public List Synonyms { get; set; } = []; - public string UsageExample { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } - public DateTime? UpdatedAt { get; set; } -} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs index 72cd87e..fca1035 100644 --- a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs +++ b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs @@ -15,7 +15,7 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi .AddEntityFrameworkStores(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); return services; } diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs index 553bced..f260b69 100644 --- a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs +++ b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs @@ -1,11 +1,10 @@ global using System.IdentityModel.Tokens.Jwt; global using System.Security.Claims; global using System.Text; -global using System.Text.Json; global using CleanApiStarter.Application.Common.Models; global using CleanApiStarter.Application.Features.Auth; -global using CleanApiStarter.Application.Features.Words; +global using CleanApiStarter.Application.Features.Projects; global using CleanApiStarter.Configuration; global using CleanApiStarter.Domain.Entities; global using CleanApiStarter.Infrastructure.Identity; diff --git a/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs b/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs index b1d19b2..85b954f 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs @@ -3,7 +3,11 @@ namespace CleanApiStarter.Infrastructure.Persistence; public sealed class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) { - public DbSet Words => Set(); + public DbSet Projects => Set(); + + public DbSet ProjectMembers => Set(); + + public DbSet ProjectTasks => Set(); protected override void OnModelCreating(ModelBuilder builder) { diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs new file mode 100644 index 0000000..566888a --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs @@ -0,0 +1,31 @@ +namespace CleanApiStarter.Infrastructure.Persistence.Configuration; + +public sealed class ProjectConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("projects"); + + builder.HasKey(project => project.Id); + + builder.Property(project => project.Id) + .HasColumnName("id"); + + builder.Property(project => project.Name) + .HasColumnName("name") + .HasMaxLength(100) + .IsRequired(); + + builder.Property(project => project.Description) + .HasColumnName("description") + .IsRequired(); + + builder.Property(project => project.OwnerUserId) + .HasColumnName("owner_user_id") + .HasMaxLength(450) + .IsRequired(); + + builder.Property(project => project.CreatedAt) + .HasColumnName("created_at"); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs new file mode 100644 index 0000000..3816dca --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs @@ -0,0 +1,30 @@ +namespace CleanApiStarter.Infrastructure.Persistence.Configuration; + +public sealed class ProjectMemberConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("project_members"); + + builder.HasKey(member => new + { + member.ProjectId, + member.UserId + }); + + builder.Property(member => member.ProjectId) + .HasColumnName("project_id"); + + builder.Property(member => member.UserId) + .HasColumnName("user_id") + .HasMaxLength(450); + + builder.Property(member => member.CreatedAt) + .HasColumnName("created_at"); + + builder.HasOne(member => member.Project) + .WithMany(project => project.Members) + .HasForeignKey(member => member.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs new file mode 100644 index 0000000..4382072 --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs @@ -0,0 +1,46 @@ +namespace CleanApiStarter.Infrastructure.Persistence.Configuration; + +public sealed class ProjectTaskConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("project_tasks"); + + builder.HasKey(task => task.Id); + + builder.Property(task => task.Id) + .HasColumnName("id"); + + builder.Property(task => task.ProjectId) + .HasColumnName("project_id"); + + builder.Property(task => task.Title) + .HasColumnName("title") + .HasMaxLength(150) + .IsRequired(); + + builder.Property(task => task.Description) + .HasColumnName("description") + .IsRequired(); + + builder.Property(task => task.Status) + .HasColumnName("status") + .HasConversion() + .HasMaxLength(20) + .IsRequired(); + + builder.Property(task => task.DueDate) + .HasColumnName("due_date"); + + builder.Property(task => task.CreatedAt) + .HasColumnName("created_at"); + + builder.Property(task => task.CompletedAt) + .HasColumnName("completed_at"); + + builder.HasOne(task => task.Project) + .WithMany(project => project.Tasks) + .HasForeignKey(task => task.ProjectId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs deleted file mode 100644 index 61ee5f4..0000000 --- a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/WordConfiguration.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Persistence.Configuration; - -public sealed class WordConfiguration : IEntityTypeConfiguration -{ - public void Configure(EntityTypeBuilder builder) - { - builder.ToTable("words"); - - builder.HasKey(word => word.Id); - - builder.Property(word => word.Id) - .HasColumnName("id"); - - builder.Property(word => word.Text) - .HasColumnName("text") - .HasMaxLength(100) - .IsRequired(); - - builder.Property(word => word.Meaning) - .HasColumnName("meaning") - .IsRequired(); - - builder.Property(word => word.Synonyms) - .HasColumnName("synonyms") - .HasColumnType("jsonb") - .HasConversion( - synonyms => JsonSerializer.Serialize(synonyms, JsonSerializerOptions.Default), - value => JsonSerializer.Deserialize>(value, JsonSerializerOptions.Default) ?? new List()); - - builder.Property(word => word.UsageExample) - .HasColumnName("usage_example") - .IsRequired(); - - builder.Property(word => word.CreatedAt) - .HasColumnName("created_at"); - - builder.Property(word => word.UpdatedAt) - .HasColumnName("updated_at"); - } -} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs b/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs new file mode 100644 index 0000000..1b7a196 --- /dev/null +++ b/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs @@ -0,0 +1,158 @@ +namespace CleanApiStarter.Infrastructure.Repositories; + +public sealed class ProjectRepository(ApplicationDbContext dbContext) : IProjectRepository +{ + public async Task AddProjectAsync(Project project, CancellationToken cancellationToken) + { + dbContext.Projects.Add(project); + await dbContext.SaveChangesAsync(cancellationToken); + + return project.Id; + } + + public async Task GetProjectAsync(Guid id, string userId, CancellationToken cancellationToken) + { + return await dbContext.Projects + .AsNoTracking() + .Where(project => project.Id == id) + .Where(project => project.Members.Any(member => member.UserId == userId)) + .SingleOrDefaultAsync(cancellationToken); + } + + public async Task> GetProjectsAsync( + string userId, + PaginatedQuery query, + CancellationToken cancellationToken) + { + IQueryable projectsQuery = dbContext.Projects + .AsNoTracking() + .Where(project => project.Members.Any(member => member.UserId == userId)) + .OrderBy(project => project.Name); + + int totalCount = await projectsQuery.CountAsync(cancellationToken); + List projects = await projectsQuery + .Skip(query.Offset) + .Take(query.Limit) + .ToListAsync(cancellationToken); + + return new PaginatedResult + { + Items = projects, + Limit = query.Limit, + Offset = query.Offset, + TotalCount = totalCount + }; + } + + public async Task IsProjectMemberAsync(Guid projectId, string userId, CancellationToken cancellationToken) + { + return await dbContext.ProjectMembers + .AnyAsync(member => member.ProjectId == projectId && member.UserId == userId, cancellationToken); + } + + public async Task IsProjectOwnerAsync(Guid projectId, string userId, CancellationToken cancellationToken) + { + return await dbContext.Projects + .AnyAsync(project => project.Id == projectId && project.OwnerUserId == userId, cancellationToken); + } + + public async Task ProjectExistsAsync(Guid projectId, CancellationToken cancellationToken) + { + return await dbContext.Projects + .AnyAsync(project => project.Id == projectId, cancellationToken); + } + + public async Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken) + { + int rowsAffected = await dbContext.Projects + .Where(project => project.Id == id) + .ExecuteDeleteAsync(cancellationToken); + + return rowsAffected > 0; + } + + public async Task AddTaskAsync(ProjectTask task, CancellationToken cancellationToken) + { + dbContext.ProjectTasks.Add(task); + await dbContext.SaveChangesAsync(cancellationToken); + + return task.Id; + } + + public async Task GetTaskAsync( + Guid projectId, + Guid taskId, + string userId, + CancellationToken cancellationToken) + { + return await ProjectTasksForMember(projectId, userId) + .AsNoTracking() + .SingleOrDefaultAsync(task => task.Id == taskId, cancellationToken); + } + + public async Task GetTaskForUpdateAsync( + Guid projectId, + Guid taskId, + string userId, + CancellationToken cancellationToken) + { + return await ProjectTasksForMember(projectId, userId) + .SingleOrDefaultAsync(task => task.Id == taskId, cancellationToken); + } + + public async Task> GetTasksAsync( + Guid projectId, + string userId, + ProjectTaskStatus? status, + PaginatedQuery query, + CancellationToken cancellationToken) + { + IQueryable tasksQuery = ProjectTasksForMember(projectId, userId); + + if (status.HasValue) + { + tasksQuery = tasksQuery.Where(task => task.Status == status.Value); + } + + tasksQuery = tasksQuery + .AsNoTracking() + .OrderBy(task => task.Status) + .ThenBy(task => task.DueDate) + .ThenBy(task => task.Title); + + int totalCount = await tasksQuery.CountAsync(cancellationToken); + List tasks = await tasksQuery + .Skip(query.Offset) + .Take(query.Limit) + .ToListAsync(cancellationToken); + + return new PaginatedResult + { + Items = tasks, + Limit = query.Limit, + Offset = query.Offset, + TotalCount = totalCount + }; + } + + public async Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken) + { + int rowsAffected = await dbContext.ProjectTasks + .Where(task => task.ProjectId == projectId && task.Id == taskId) + .ExecuteDeleteAsync(cancellationToken); + + return rowsAffected > 0; + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + return await dbContext.SaveChangesAsync(cancellationToken); + } + + private IQueryable ProjectTasksForMember(Guid projectId, string userId) + { + return dbContext.ProjectTasks + .Where(task => task.ProjectId == projectId) + .Where(task => task.Project.Members.Any(member => member.UserId == userId)); + } +} \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs b/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs deleted file mode 100644 index dcd5f3c..0000000 --- a/src/CleanApiStarter.Infrastructure/Repositories/WordRepository.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Repositories; - -public class WordRepository(ApplicationDbContext dbContext) : IWordRepository -{ - public async Task AddWordAsync(Word word, CancellationToken cancellationToken) - { - dbContext.Words.Add(word); - await dbContext.SaveChangesAsync(cancellationToken); - - return word.Id; - } - - public async Task GetWordByIdAsync(Guid id, CancellationToken cancellationToken) - { - return await dbContext.Words - .AsNoTracking() - .SingleOrDefaultAsync(word => word.Id == id, cancellationToken); - } - - public async Task> GetWordsAsync(PaginatedQuery query, CancellationToken cancellationToken) - { - IQueryable wordsQuery = dbContext.Words - .AsNoTracking() - .OrderBy(word => word.Text); - - int totalCount = await wordsQuery.CountAsync(cancellationToken); - List words = await wordsQuery - .Skip(query.Offset) - .Take(query.Limit) - .ToListAsync(cancellationToken); - - return new PaginatedResult - { - Items = words, - Limit = query.Limit, - Offset = query.Offset, - TotalCount = totalCount - }; - } - - public async Task UpdateWordAsync(Word word, CancellationToken cancellationToken) - { - dbContext.Words.Update(word); - int rowsAffected = await dbContext.SaveChangesAsync(cancellationToken); - - return rowsAffected > 0; - } - - public async Task DeleteWordAsync(Guid id, CancellationToken cancellationToken) - { - int rowsAffected = await dbContext.Words - .Where(word => word.Id == id) - .ExecuteDeleteAsync(cancellationToken); - - return rowsAffected > 0; - } -} \ No newline at end of file diff --git a/tests/CleanApiStarter.Api.IntegerationTests/CleanApiStarter.Api.IntegerationTests.csproj b/tests/CleanApiStarter.Api.IntegerationTests/CleanApiStarter.Api.IntegerationTests.csproj new file mode 100644 index 0000000..896777a --- /dev/null +++ b/tests/CleanApiStarter.Api.IntegerationTests/CleanApiStarter.Api.IntegerationTests.csproj @@ -0,0 +1,15 @@ + + + + CleanApiStarter.Api.IntegerationTests + CleanApiStarter.Api.IntegerationTests + + + + + + + + + + \ No newline at end of file diff --git a/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj b/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj similarity index 76% rename from tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj rename to tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj index f36e667..1b55c1c 100644 --- a/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj +++ b/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj @@ -1,7 +1,7 @@ - CleanApiStarter.UnitTests - CleanApiStarter.UnitTests + CleanApiStarter.Application.UnitTests + CleanApiStarter.Application.UnitTests diff --git a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj b/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj new file mode 100644 index 0000000..5b915d4 --- /dev/null +++ b/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj @@ -0,0 +1,15 @@ + + + + CleanApiStarter.Tests + CleanApiStarter.Tests + + + + + + + + + + \ No newline at end of file From f3ada0454cd5d652e080bb21d0973edf6b98389d Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Fri, 1 May 2026 23:26:28 +0800 Subject: [PATCH 08/33] add integration and unit testing setup; include test configurations, helpers, and coverage scripts --- .config/dotnet-tools.json | 17 ++- AGENTS.md | 12 +++ CleanApiStarter.slnx | 2 +- Directory.Packages.props | 14 ++- scripts/test-coverage.sh | 47 ++++++++ src/CleanApiStarter.Api/Program.cs | 4 +- ...eanApiStarter.Api.IntegerationTests.csproj | 15 --- ...leanApiStarter.Api.IntegrationTests.csproj | 25 +++++ .../Features/Projects/ProjectsTests.cs | 55 ++++++++++ .../GlobalUsings.cs | 9 ++ .../appsettings.Testing.json | 16 +++ ...eanApiStarter.Application.UnitTests.csproj | 7 +- .../Features/Projects/ProjectServiceTests.cs | 40 +++++++ .../GlobalUsings.cs | 14 +++ .../CleanApiStarter.Tests.csproj | 12 ++- .../Common/ApiApplicationFactory.cs | 100 ++++++++++++++++++ .../Common/AutoNSubstituteDataAttribute.cs | 13 +++ tests/CleanApiStarter.Tests/GlobalUsings.cs | 16 +++ 18 files changed, 385 insertions(+), 33 deletions(-) create mode 100755 scripts/test-coverage.sh delete mode 100644 tests/CleanApiStarter.Api.IntegerationTests/CleanApiStarter.Api.IntegerationTests.csproj create mode 100644 tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj create mode 100644 tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs create mode 100644 tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs create mode 100644 tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json create mode 100644 tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs create mode 100644 tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs create mode 100644 tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs create mode 100644 tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs create mode 100644 tests/CleanApiStarter.Tests/GlobalUsings.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 517f9b3..a4ed703 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -1,5 +1,12 @@ -{ - "version": 1, - "isRoot": true, - "tools": {} -} \ No newline at end of file +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "5.5.5", + "commands": [ + "reportgenerator" + ] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 3addae4..4b1a93e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,18 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Keep `PackageVersion` items sorted alphabetically by `Include`. - Do not add package versions directly in individual `.csproj` files. +## Testing + +- Use xUnit v3, AutoFixture.xUnit3, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly for unit tests. +- Reusable test helpers belong in `CleanApiStarter.Tests`. +- Application unit tests should reference `CleanApiStarter.Tests` instead of duplicating common test setup. +- API integration tests use MSTest and Testcontainers for Postgres. +- API integration tests should keep real JWT bearer authentication active. Generate test JWTs from `appsettings.Testing.json` instead of replacing authentication with a fake scheme. +- API integration tests should start a Postgres Testcontainer and apply SQL scripts from `database/migrations`. +- Keep test app settings in `appsettings.Testing.json`. +- Test methods must follow the naming pattern `UnitOfWork_StateUnderTest_ExpectedBehavior`. +- Tests must follow AAA format with explicit `// Arrange`, `// Act`, and `// Assert` sections. + ## Verification - After structural or package changes, run: diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx index f756d09..9c7a5fa 100644 --- a/CleanApiStarter.slnx +++ b/CleanApiStarter.slnx @@ -27,7 +27,7 @@ - + diff --git a/Directory.Packages.props b/Directory.Packages.props index fa34cb3..4e0ba54 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,15 +13,16 @@ - + + - + @@ -33,7 +34,9 @@ - + + + @@ -47,7 +50,8 @@ - - + + + diff --git a/scripts/test-coverage.sh b/scripts/test-coverage.sh new file mode 100755 index 0000000..2d23317 --- /dev/null +++ b/scripts/test-coverage.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +coverage_dir="$repo_root/artifacts/coverage" +test_results_dir="$coverage_dir/test-results" +coverage_report_dir="$coverage_dir/report" + +cd "$repo_root" + +rm -rf "$coverage_dir" +mkdir -p "$test_results_dir" "$coverage_report_dir" + +dotnet tool restore + +dotnet test CleanApiStarter.slnx \ + --collect:"XPlat Code Coverage" \ + --disable-build-servers \ + --results-directory "$test_results_dir" \ + --logger trx \ + /m:1 \ + /nr:false \ + -v:minimal + +coverage_files=() + +while IFS= read -r coverage_file; do + coverage_files+=("$coverage_file") +done < <(find "$test_results_dir" -path "*/In/*" -prune -o -type f -name "coverage.cobertura.xml" -print | sort) + +if [ "${#coverage_files[@]}" -eq 0 ]; then + echo "No coverage.cobertura.xml files were generated." + exit 1 +fi + +coverage_reports="$(IFS=';'; echo "${coverage_files[*]}")" + +dotnet reportgenerator \ + "-reports:$coverage_reports" \ + "-targetdir:$coverage_report_dir" \ + "-reporttypes:Html" \ + "-title:CleanApiStarter Coverage" + +coverage_index="$coverage_report_dir/index.html" + +echo "Coverage report: $coverage_index" +open -a "Google Chrome" "$coverage_index" diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs index 5e40fa7..452e9af 100644 --- a/src/CleanApiStarter.Api/Program.cs +++ b/src/CleanApiStarter.Api/Program.cs @@ -35,4 +35,6 @@ app.MapEndpoints(Assembly.GetExecutingAssembly()); app.MapDefaultEndpoints(); -app.Run(); \ No newline at end of file +app.Run(); + +public partial class Program; \ No newline at end of file diff --git a/tests/CleanApiStarter.Api.IntegerationTests/CleanApiStarter.Api.IntegerationTests.csproj b/tests/CleanApiStarter.Api.IntegerationTests/CleanApiStarter.Api.IntegerationTests.csproj deleted file mode 100644 index 896777a..0000000 --- a/tests/CleanApiStarter.Api.IntegerationTests/CleanApiStarter.Api.IntegerationTests.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - CleanApiStarter.Api.IntegerationTests - CleanApiStarter.Api.IntegerationTests - - - - - - - - - - \ No newline at end of file diff --git a/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj b/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj new file mode 100644 index 0000000..fd36048 --- /dev/null +++ b/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj @@ -0,0 +1,25 @@ + + + + CleanApiStarter.Api.IntegrationTests + CleanApiStarter.Api.IntegrationTests + + + + + + + + + + + + + + + + + + + + diff --git a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs b/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs new file mode 100644 index 0000000..253ff64 --- /dev/null +++ b/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs @@ -0,0 +1,55 @@ +namespace CleanApiStarter.Api.IntegrationTests.Features.Projects; + +[TestClass] +public sealed class ProjectsTests +{ + private ApiApplicationFactory _applicationFactory = null!; + + private HttpClient _client = null!; + + [TestInitialize] + public async Task TestInitialize() + { + _applicationFactory = new ApiApplicationFactory(); + await _applicationFactory.InitializeAsync(); + + _client = _applicationFactory.CreateClient(); + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", + _applicationFactory.CreateAccessToken("integration-test-user")); + } + + [TestCleanup] + public async Task TestCleanup() + { + _client.Dispose(); + await _applicationFactory.DisposeAsync(); + } + + [TestMethod] + public async Task Projects_PostAuthenticatedRequest_CreatesProject() + { + // Arrange + object request = new + { + Name = "Integration test project", + Description = "Created through the API integration test host" + }; + + // Act + HttpResponseMessage response = await _client.PostAsJsonAsync("/api/projects", request, + cancellationToken: TestContext.CancellationToken); + + // Assert + response.StatusCode.ShouldBe(HttpStatusCode.Created); + + Guid projectId = await response.Content.ReadFromJsonAsync(); + projectId.ShouldNotBe(Guid.Empty); + + response.Headers.Location.ShouldNotBeNull(); + response.Headers.Location!.ToString().ShouldContain($"/api/projects/{projectId}"); + } + + public TestContext TestContext { get; set; } +} \ No newline at end of file diff --git a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs b/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs new file mode 100644 index 0000000..1435a22 --- /dev/null +++ b/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs @@ -0,0 +1,9 @@ +global using System.Net; +global using System.Net.Http.Headers; +global using System.Net.Http.Json; + +global using CleanApiStarter.Tests.Common; + +global using Microsoft.VisualStudio.TestTools.UnitTesting; + +global using Shouldly; \ No newline at end of file diff --git a/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json b/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json new file mode 100644 index 0000000..8fdbc0f --- /dev/null +++ b/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json @@ -0,0 +1,16 @@ +{ + "ConnectionStrings": { + "Postgres": "Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres" + }, + "Authentication": { + "Google": { + "ClientId": "integration-tests" + }, + "Jwt": { + "Issuer": "CleanApiStarter.Tests", + "Audience": "CleanApiStarter.Api.Tests", + "SigningKey": "integration-tests-signing-key-change-me", + "ExpirationMinutes": 60 + } + } +} diff --git a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj b/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj index 1b55c1c..9f3d723 100644 --- a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj +++ b/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj @@ -5,14 +5,19 @@ + + + - + + + diff --git a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs b/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs new file mode 100644 index 0000000..fd03231 --- /dev/null +++ b/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs @@ -0,0 +1,40 @@ +namespace CleanApiStarter.Application.UnitTests.Features.Projects; + +public sealed class ProjectServiceTests +{ + [Theory] + [AutoNSubstituteData] + public async Task CompleteTaskAsync_TaskIsAlreadyCompleted_ReturnsAlreadyCompleted( + Guid projectId, + Guid taskId, + string userId, + [Frozen] IUser currentUser, + [Frozen] IProjectRepository projectRepository, + ProjectService sut) + { + // Arrange + CancellationToken cancellationToken = CancellationToken.None; + ProjectTask task = new() + { + Id = taskId, + ProjectId = projectId, + Title = "Write first unit test", + Description = "Cover already completed tasks", + Status = ProjectTaskStatus.Done, + CreatedAt = DateTime.UtcNow, + CompletedAt = DateTime.UtcNow + }; + + currentUser.Id.Returns(userId); + projectRepository + .GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken) + .Returns(Task.FromResult(task)); + + // Act + ProjectTaskMutationResult result = await sut.CompleteTaskAsync(projectId, taskId, cancellationToken); + + // Assert + result.ShouldBe(ProjectTaskMutationResult.AlreadyCompleted); + _ = projectRepository.DidNotReceive().SaveChangesAsync(Arg.Any()); + } +} \ No newline at end of file diff --git a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs b/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..6c472a4 --- /dev/null +++ b/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs @@ -0,0 +1,14 @@ +global using AutoFixture.Xunit3; + +global using CleanApiStarter.Application.Common.Interfaces; +global using CleanApiStarter.Application.Features.Projects; +global using CleanApiStarter.Domain.Entities; +global using CleanApiStarter.Tests.Common; + +global using Microsoft.Extensions.Logging; + +global using NSubstitute; + +global using Shouldly; + +global using Xunit; \ No newline at end of file diff --git a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj b/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj index 5b915d4..5faf723 100644 --- a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj +++ b/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj @@ -6,10 +6,12 @@ - - - - + + + + + + - \ No newline at end of file + diff --git a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs b/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs new file mode 100644 index 0000000..1277724 --- /dev/null +++ b/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs @@ -0,0 +1,100 @@ +namespace CleanApiStarter.Tests.Common; + +public sealed class ApiApplicationFactory : WebApplicationFactory + where TProgram : class +{ + private const string TestIssuer = "CleanApiStarter.Tests"; + private const string TestAudience = "CleanApiStarter.Api.Tests"; + private const string TestSigningKey = "integration-tests-signing-key-change-me"; + + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:latest") + .WithDatabase("postgres") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + public async Task InitializeAsync() + { + await postgres.StartAsync(); + await RunDatabaseScriptsAsync(); + } + + public string CreateAccessToken(string userId) + { + SymmetricSecurityKey securityKey = new(Encoding.UTF8.GetBytes(TestSigningKey)); + SigningCredentials credentials = new(securityKey, SecurityAlgorithms.HmacSha256); + List claims = + [ + new(JwtRegisteredClaimNames.Sub, userId), + new(ClaimTypes.NameIdentifier, userId), + new(ClaimTypes.Email, $"{userId}@example.test"), + new(ClaimTypes.Name, userId), + new(ClaimTypes.Role, "User") + ]; + + JwtSecurityToken token = new( + issuer: TestIssuer, + audience: TestAudience, + claims: claims, + expires: DateTime.UtcNow.AddMinutes(60), + signingCredentials: credentials); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + builder.ConfigureAppConfiguration(configurationBuilder => + { + string settingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.Testing.json"); + Dictionary connectionStringOverride = new() + { + ["ConnectionStrings:Postgres"] = postgres.GetConnectionString() + }; + + configurationBuilder.AddJsonFile(settingsPath, optional: false); + configurationBuilder.AddInMemoryCollection(connectionStringOverride); + }); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + await postgres.DisposeAsync(); + } + + private async Task RunDatabaseScriptsAsync() + { + await using NpgsqlConnection connection = new(postgres.GetConnectionString()); + await connection.OpenAsync(); + + foreach (string scriptPath in GetDatabaseScriptPaths()) + { + string sql = await File.ReadAllTextAsync(scriptPath); + + await using NpgsqlCommand command = new(sql, connection); + await command.ExecuteNonQueryAsync(); + } + } + + private static IEnumerable GetDatabaseScriptPaths() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + + while (directory != null) + { + string migrationsPath = Path.Combine(directory.FullName, "database", "migrations"); + + if (Directory.Exists(migrationsPath)) + { + return Directory.GetFiles(migrationsPath, "*.sql").Order(StringComparer.Ordinal); + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException("Could not find database/migrations from the test output directory."); + } +} \ No newline at end of file diff --git a/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs b/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs new file mode 100644 index 0000000..09eb258 --- /dev/null +++ b/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs @@ -0,0 +1,13 @@ +namespace CleanApiStarter.Tests.Common; + +public sealed class AutoNSubstituteDataAttribute() : AutoDataAttribute(CreateFixture) +{ + private static IFixture CreateFixture() + { + Fixture fixture = new(); + + fixture.Customize(new AutoNSubstituteCustomization()); + + return fixture; + } +} \ No newline at end of file diff --git a/tests/CleanApiStarter.Tests/GlobalUsings.cs b/tests/CleanApiStarter.Tests/GlobalUsings.cs new file mode 100644 index 0000000..c425319 --- /dev/null +++ b/tests/CleanApiStarter.Tests/GlobalUsings.cs @@ -0,0 +1,16 @@ +global using System.IdentityModel.Tokens.Jwt; +global using System.Security.Claims; +global using System.Text; + +global using AutoFixture; +global using AutoFixture.AutoNSubstitute; +global using AutoFixture.Xunit3; + +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.AspNetCore.Mvc.Testing; +global using Microsoft.Extensions.Configuration; +global using Microsoft.IdentityModel.Tokens; + +global using Npgsql; + +global using Testcontainers.PostgreSql; \ No newline at end of file From 17d2e677296f5fa519a67795b439bacff64be444 Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Fri, 1 May 2026 23:35:11 +0800 Subject: [PATCH 09/33] update README.md to enhance project description, features, and usage instructions; improve clarity on architecture, authentication, and testing --- README.md | 538 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 490 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 3d51036..482e97f 100644 --- a/README.md +++ b/README.md @@ -1,104 +1,519 @@ # CleanApiStarter -A Clean Architecture API starter template for .NET, Aspire, PostgreSQL, and OpenTelemetry. +`CleanApiStarter` is a Clean Architecture API starter template for .NET 10, ASP.NET Core Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API versioning, FluentValidation, Scalar, EF Core, and automated tests. + +The project is intentionally both a template and a working reference application. The sample domain is project management: authenticated users can create projects, manage project tasks, filter tasks by status, and complete tasks. + +## Features + +- Clean Architecture solution structure with separate `Api`, `Application`, `Domain`, `Infrastructure`, `Configuration`, and shared ASP.NET Core defaults projects. +- Minimal APIs organized by endpoint group and API version folders. +- Header-based API versioning with optional `X-Api-Version`; missing versions default to v1. +- Versioned OpenAPI documents shown with Scalar. +- Google ID token sign-in that issues the API's own JWT. +- ASP.NET Core Identity for local users, roles, and external login storage. +- Real JWT bearer authentication for protected APIs. +- EF Core with PostgreSQL for application data and Identity storage. +- PostgreSQL local development through Aspire or Docker Compose. +- Database schema scripts under `database/migrations`. +- OpenTelemetry traces, metrics, and structured logs through Aspire-friendly defaults. +- HTTP request logging with authenticated user id. +- `X-Request-ID` response header containing the current trace id. +- Problem Details exception responses. +- Response compression with Brotli and gzip. +- FluentValidation endpoint validation returning `422 Unprocessable Entity`. +- Result wrappers for collection endpoints: `PaginatedResult` and `ArrayResult`. +- xUnit v3 unit tests with AutoFixture, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly. +- MSTest API integration tests with Testcontainers for PostgreSQL. +- Local coverage script that generates an HTML report with ReportGenerator. + +## Solution Layout -The API uses Minimal APIs with endpoint group classes. `CleanApiStarter.AspNetCore` discovers endpoint groups from the API assembly so `Program.cs` stays small while routes remain easy to find. +```text +CleanApiStarter +├── database +│ └── migrations +├── scripts +├── src +│ ├── CleanApiStarter.Api +│ ├── CleanApiStarter.Application +│ ├── CleanApiStarter.Domain +│ ├── CleanApiStarter.Infrastructure +│ └── Common +│ ├── CleanApiStarter.AppHost +│ ├── CleanApiStarter.AspNetCore +│ └── CleanApiStarter.Configuration +└── tests + ├── CleanApiStarter.Api.IntegrationTests + ├── CleanApiStarter.Application.UnitTests + └── CleanApiStarter.Tests +``` + +Layer dependencies point inward: + +- `Domain` references nothing. +- `Application` references `Domain`. +- `Infrastructure` references `Application` and `Configuration`. +- `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. +- `Configuration` contains plain settings classes and options registration. +- `AspNetCore` contains reusable web/runtime defaults. +- `AppHost` contains Aspire orchestration. + +## Requirements + +- .NET SDK `10.0.203` or compatible latest feature SDK +- Docker Desktop +- Google Chrome, only for the local coverage script opening the HTML report + +The SDK is pinned in `global.json`: + +```json +{ + "sdk": { + "version": "10.0.203", + "rollForward": "latestFeature" + } +} +``` + +## Run With Aspire + +```bash +dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj +``` + +Aspire starts: + +- the API +- a PostgreSQL container using `postgres:latest` +- pgAdmin +- the Aspire dashboard + +Open the Aspire dashboard URL printed in the terminal. Use it to inspect resources, logs, traces, metrics, health, and PostgreSQL activity. + +The AppHost configures PostgreSQL like this: -API versions are selected with the optional `X-Api-Version` request header. Requests without a version use v1 by default. Endpoint groups live under version folders such as `Endpoints/V1` and `Endpoints/V2`. -OpenAPI documents are generated per API version and shown in Scalar with a version selector. +- server resource: `postgres-server` +- database resource: `postgres` +- data volume: `clean-api-starter-postgres-data` +- volume mount: `/var/lib/postgresql` +- init scripts: `database/migrations` -Collection endpoints return an `ArrayResult` envelope when they are not paginated. Paginated endpoints accept `limit` and `offset` query parameters and return `PaginatedResult` metadata with the items. Single-resource endpoints return the resource object directly. +PostgreSQL init scripts run only when the database volume is first created. If you change scripts and want them replayed locally, delete the old Docker volume. -The reference feature is project management. Users can create projects, list their projects, manage tasks inside projects, filter tasks by status, and complete tasks. Project membership controls task visibility, and project deletion is owner-only. +## Run With Docker Compose + +If you want only PostgreSQL without Aspire: + +```bash +docker compose up -d +dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +``` + +`docker-compose.yml` starts one database named `postgres` and mounts `database/migrations` into the Postgres init directory. + +## Database + +Database scripts live here: + +```text +database/migrations +├── V001__create_projects_and_tasks_tables.sql +└── V002__create_identity_tables.sql +``` + +The application uses EF Core through `ApplicationDbContext`, but local schema creation is owned by the SQL scripts. There are no DbUp calls or API startup database initializers. + +`ApplicationDbContext` lives in: + +```text +src/CleanApiStarter.Infrastructure/Persistence +``` + +EF Core mappings live in: + +```text +src/CleanApiStarter.Infrastructure/Persistence/Configuration +``` + +## API Style + +The API uses Minimal APIs. `Program.cs` stays small and delegates endpoint registration to endpoint group classes. + +Endpoint groups live under version folders: + +```text +src/CleanApiStarter.Api/Endpoints/V1 +src/CleanApiStarter.Api/Endpoints/V2 +``` + +Each endpoint group implements `IEndpointGroup` from `CleanApiStarter.AspNetCore` and is discovered through: + +```csharp +app.MapEndpoints(Assembly.GetExecutingAssembly()); +``` + +Endpoint names are globally unique across versions, for example: + +```text +GetProjectsV1 +GetProjectsV2 +``` + +## API Versioning + +API versioning uses header-based version selection: + +```http +X-Api-Version: 1.0 +``` + +The header is optional. Requests without `X-Api-Version` use v1. + +The project uses the .NET 10 API versioning/OpenAPI package setup: + +- `Asp.Versioning.Http` +- `Asp.Versioning.Mvc.ApiExplorer` +- `Asp.Versioning.OpenApi` +- `Microsoft.AspNetCore.OpenApi` + +OpenAPI documents are generated per version and displayed in Scalar. + +## Scalar + +This project uses Scalar instead of Swagger/Swashbuckle. + +When running in Development, OpenAPI and Scalar are mapped by the API: + +- versioned OpenAPI documents +- Scalar API reference with version selection ## Authentication -The API validates Google ID tokens and issues its own JWT access token. Google proves the user identity; ASP.NET Core Identity stores local users, roles, and external logins. +Authentication is API-first. + +The intended production flow is: + +1. A client obtains a Google ID token. +2. The client sends it to `POST /api/auth/google`. +3. The API validates the Google ID token. +4. The API creates or updates the local ASP.NET Core Identity user. +5. The API issues its own JWT. +6. Protected API calls use: + +```http +Authorization: Bearer +``` + +The API does not use cookies as the default authentication mechanism. + +### Google Client ID -Set the Google client id with user-secrets: +Create an OAuth client in Google Cloud Console and set the client id with user secrets: ```bash dotnet user-secrets set "Authentication:Google:ClientId" "" --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj ``` -For local testing, open the development-only helper page: +For local browser testing, open the development helper page: ```text https://localhost:7285/auth/google-login ``` -After signing in with Google, the page calls `POST /api/auth/google` and shows the API JWT. Use that JWT with protected endpoints: +The helper page signs in with Google, calls the API token endpoint, displays the API JWT, and includes a copy button. + +## Authorization + +Protected endpoint groups call `RequireAuthorization()` once at the group level, so individual project/task endpoints do not need repeated authorization declarations. + +Project authorization rules are implemented in the application service and repository queries: + +- users can only see projects they belong to +- users can only see and manage tasks inside projects they belong to +- project deletion is owner-only + +## Application Features + +The reference feature is project and task management. + +Projects: + +- create project +- list current user's projects +- get project by id +- delete project as owner + +Tasks: + +- create task under a project +- list tasks with `limit` and `offset` +- filter tasks by status +- get task by id +- update task +- complete task +- delete task + +The application project is organized by feature: + +```text +src/CleanApiStarter.Application/Features/Auth +src/CleanApiStarter.Application/Features/Projects +``` + +Cross-cutting abstractions live under: + +```text +src/CleanApiStarter.Application/Common +``` + +## Response Shapes + +Single-resource endpoints return the resource DTO directly. + +Paginated endpoints return `PaginatedResult`: + +```json +{ + "items": [], + "limit": 20, + "offset": 0, + "totalCount": 0, + "hasPreviousPage": false, + "hasNextPage": false +} +``` + +Non-paginated collection endpoints should return `ArrayResult`: + +```json +{ + "items": [], + "count": 0 +} +``` + +## Validation + +Request validation uses FluentValidation, not DataAnnotations. + +Validators live beside request models in the `Application` project. The shared Minimal API validation filter lives in `CleanApiStarter.AspNetCore`. + +Validation failures return: ```http -Authorization: Bearer +422 Unprocessable Entity +``` + +## Configuration + +Configuration classes live in `CleanApiStarter.Configuration`. + +The root configuration object is: + +```csharp +AppSettings +``` + +It includes: + +- `ConnectionStrings.Postgres` +- `Authentication.Google.ClientId` +- `Authentication.Jwt.Issuer` +- `Authentication.Jwt.Audience` +- `Authentication.Jwt.SigningKey` +- `Authentication.Jwt.ExpirationMinutes` + +The API registers settings once: + +```csharp +builder.Services.AddAppSettings(builder.Configuration); +``` + +Services can inject `AppSettings` directly. + +## Observability + +`CleanApiStarter.AspNetCore` centralizes runtime defaults: + +- OpenTelemetry traces +- OpenTelemetry metrics +- OpenTelemetry logs +- OTLP export for Aspire +- structured log formatting +- HTTP request logging +- health checks +- service discovery +- HTTP client resilience +- problem details +- response compression +- request id header +- Kestrel `Server` header removal + +When the API runs under Aspire, inspect the dashboard for: + +- API request traces +- Npgsql database traces +- structured logs +- request logs with `UserId` +- runtime metrics +- resource health + +Every response includes: + +```http +X-Request-ID: +``` + +Use this value to correlate client responses with logs and traces. + +## Health And Version Endpoints + +The shared ASP.NET Core defaults map: + +```text +/health +/alive +/version +``` + +`/version` exposes the running application version. + +## Testing + +### Unit Tests + +Application unit tests use: + +- xUnit v3 +- AutoFixture.xUnit3 +- AutoFixture.AutoNSubstitute +- NSubstitute +- Shouldly + +Common test helpers live in: + +```text +tests/CleanApiStarter.Tests ``` -New Google users are created as local Identity users and assigned the `User` role by default. The database init scripts create the `User` and `Admin` roles. +Current reusable helpers: -## Learn OpenTelemetry with .NET Aspire +- `AutoNSubstituteDataAttribute` +- `ApiApplicationFactory` -This solution can run the existing API with a local PostgreSQL container through .NET Aspire. Aspire also provides the local dashboard where you can inspect logs, traces, metrics, and resource health. +Test names follow: -### Run with Aspire +```text +UnitOfWork_StateUnderTest_ExpectedBehavior +``` + +Tests use AAA sections: + +```csharp +// Arrange +// Act +// Assert +``` + +Run unit tests: ```bash -dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj +dotnet test tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj ``` -Open the Aspire dashboard URL printed in the terminal. The AppHost starts: +### API Integration Tests -- `api`: the ASP.NET Core API -- `postgres`: a local PostgreSQL container -- `postgres`: the API database -- `pgAdmin`: a browser UI for inspecting PostgreSQL +API integration tests use: -The API receives its `postgres` connection string from Aspire, which binds to `ConnectionStrings:Postgres`. Running the API directly uses `ConnectionStrings:Postgres` from `src/CleanApiStarter.Api/appsettings.Development.json`, which matches the existing `docker-compose.yml`. +- MSTest +- Microsoft.AspNetCore.Mvc.Testing +- Testcontainers for PostgreSQL +- Shouldly -Database scripts live in `database`. Aspire and Docker Compose copy `database/migrations` into the Postgres container init folder. Docker runs them only when the Postgres data directory is created for the first time. Because the AppHost uses the persistent `clean-api-starter-postgres-data` volume, delete that Docker volume if you need to replay the scripts from scratch. +The integration test factory starts a real Postgres container, applies scripts from `database/migrations`, boots the API through `WebApplicationFactory`, and creates real JWTs from `appsettings.Testing.json`. -- `database/migrations`: versioned schema scripts that can also be reused by CI/CD migration tools such as Flyway. +The API integration tests keep JWT bearer authentication active. They do not replace authentication with a fake scheme. -### What to Observe +Run integration tests: -After the dashboard is running, send requests to the API through Scalar or another HTTP client. In the Aspire dashboard: +```bash +dotnet test tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj +``` -- Logs show structured application and framework log entries. The project and task use cases emit fields such as `ProjectId`, `TaskId`, `ProjectCount`, and `UserId`. -- Request logs show method, path, response status code, duration, and `UserId` without logging request or response bodies. `CleanApiStarter.AspNetCore` keeps the `Microsoft.AspNetCore.HttpLogging` category at `Information` even when broader ASP.NET Core logs are filtered to `Warning`. -- Responses include an `X-Request-ID` header containing the current OpenTelemetry trace id, which can be used to correlate API responses with Aspire traces and logs. -- Responses support Brotli and gzip compression when clients send an `Accept-Encoding` header. -- Traces show incoming HTTP requests and PostgreSQL commands from Npgsql. -- Metrics show ASP.NET Core, HTTP client, and .NET runtime measurements. -- `/version` exposes the running application version. -- Health checks expose `/health` and `/alive` in Development. +Run all tests: -### Coding Conventions +```bash +dotnet test CleanApiStarter.slnx +``` -Cancellation tokens are explicit at application and infrastructure boundaries. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. API actions should accept a `CancellationToken` parameter and pass it through the application and repository calls. +## Coverage -Use structured logging message templates instead of interpolated log strings. Prefer stable property names such as `{ProjectId}` or `{TaskId}` so Aspire and OpenTelemetry can index and filter them. +Generate coverage and open the HTML report in Chrome: -Configuration classes live in `CleanApiStarter.Configuration`. Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly where configuration values are needed. +```bash +scripts/test-coverage.sh +``` + +The script: + +- deletes the previous `artifacts/coverage` folder +- restores local .NET tools +- runs tests with `XPlat Code Coverage` +- generates an HTML report with ReportGenerator +- opens the report in Google Chrome -Request validation uses FluentValidation. Validators live beside their feature request models in the `Application` project and are executed by a Minimal API endpoint filter from `CleanApiStarter.AspNetCore`. FluentValidation failures return `422 Unprocessable Entity`. +Coverage output: + +```text +artifacts/coverage/report/index.html +``` -### Run with Docker Compose Only +## Build -If you want to run PostgreSQL without Aspire: +Restore and build: ```bash -docker compose up -d -dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +dotnet restore CleanApiStarter.slnx --disable-parallel +dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal +``` + +## Package Management + +Package versions are managed centrally in: + +```text +Directory.Packages.props ``` -### Troubleshooting Aspire Certificates +Keep package versions sorted alphabetically by `Include`. Do not add package versions directly to individual project files. + +## Coding Conventions + +- Use explicit local types. +- Use project-level `GlobalUsings.cs`. +- Keep cancellation tokens explicit. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. +- Use `required` and `init` for required non-null DTO and domain properties. +- Use nullable types only for genuinely optional values. +- Use structured logging message templates instead of interpolated log strings. +- Keep repository interfaces in `Application`. +- Keep repository implementations and EF Core details in `Infrastructure`. +- Keep domain models persistence-agnostic. +- Do not reintroduce Dapper for the default data access path. +- Do not reintroduce MVC controllers unless the template intentionally changes direction. +- Do not add Swashbuckle; use Scalar. + +## Troubleshooting + +### Aspire certificate errors -If the Aspire dashboard logs an `UntrustedRoot` error while calling gRPC services, or the AppHost prints `No trusted Aspire development certificate was found`, trust the local ASP.NET Core development certificate: +If Aspire logs an `UntrustedRoot` error or reports that no trusted development certificate exists, trust the local ASP.NET Core development certificate: ```bash dotnet dev-certs https --check --trust ``` -If the certificate is missing or untrusted, reset it and trust it again: +If needed, reset and trust again: ```bash dotnet dev-certs https --clean @@ -106,8 +521,35 @@ dotnet dev-certs https --trust dotnet dev-certs https --check --trust ``` -On macOS, accept the Keychain prompt and enter your password if requested. Then close and reopen the browser, and start Aspire again: +On macOS, accept the Keychain prompt and enter your password if requested. + +### PostgreSQL init scripts did not run + +Postgres init scripts only run when the data directory is created. Delete the existing volume and start again. + +For Docker Compose: ```bash -dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj +docker compose down -v +docker compose up -d ``` + +For Aspire, delete the `clean-api-starter-postgres-data` Docker volume. + +### Postgres latest and data volumes + +This template uses `postgres:latest`. PostgreSQL 18+ expects data mounted at: + +```text +/var/lib/postgresql +``` + +Do not mount the volume at: + +```text +/var/lib/postgresql/data +``` + +## License + +This project is licensed under the GNU General Public License v3.0. See `LICENSE` for details. From 873fbbf8600c7778bb607e2d1d1586ecbef2967f Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Mon, 4 May 2026 07:44:19 +0800 Subject: [PATCH 10/33] update project structure and add template packaging; configure GitHub Actions for CI, template verification, and NuGet publishing --- .editorconfig | 6 +- .github/workflows/build.yml | 33 ++++++++ .github/workflows/codeql.yml | 44 ++++++++++ .github/workflows/release.yml | 70 ++++++++++++++++ .github/workflows/template.yml | 49 ++++++++++++ .template.config/template.json | 51 ++++++++++++ AGENTS.md | 12 ++- CleanApiStarter.nuspec | 24 ++++++ CleanApiStarter.slnx | 11 +++ README.md | 47 ++++++++++- scripts/install-template.sh | 13 +++ .../Endpoints/GoogleLoginPage.cs | 2 +- src/CleanApiStarter.Api/Endpoints/V1/Auth.cs | 2 +- .../Endpoints/V1/Projects.cs | 2 +- .../Endpoints/V2/Projects.cs | 2 +- src/CleanApiStarter.Api/GlobalUsings.cs | 2 +- src/CleanApiStarter.Api/Program.cs | 80 +++++++++---------- .../Services/CurrentUser.cs | 2 +- src/CleanApiStarter.AppHost/Program.cs | 38 ++++----- .../Common/Interfaces/IUser.cs | 2 +- .../Common/Models/ArrayResult.cs | 2 +- .../Common/Models/PaginatedQuery.cs | 2 +- .../Common/Models/PaginatedQueryValidator.cs | 2 +- .../Common/Models/PaginatedResult.cs | 2 +- .../DependencyInjection.cs | 24 +++--- .../Features/Auth/AuthTokenDto.cs | 2 +- .../Features/Auth/CurrentUserDto.cs | 2 +- .../Features/Auth/GoogleSignInDto.cs | 2 +- .../Features/Auth/GoogleSignInDtoValidator.cs | 2 +- .../Features/Auth/IAuthService.cs | 2 +- .../Projects/CreateProjectDtoValidator.cs | 2 +- .../Projects/CreateProjectTaskDtoValidator.cs | 2 +- .../Features/Projects/IProjectRepository.cs | 2 +- .../Features/Projects/IProjectService.cs | 2 +- .../Features/Projects/ProjectDto.cs | 2 +- .../Projects/ProjectOperationResults.cs | 2 +- .../Features/Projects/ProjectService.cs | 2 +- .../Features/Projects/ProjectTaskDto.cs | 2 +- .../Projects/UpdateProjectTaskDtoValidator.cs | 2 +- .../GlobalUsings.cs | 22 ++--- .../AspNetCoreDefaultServices.cs | 2 +- src/CleanApiStarter.AspNetCore/Extensions.cs | 2 +- .../GlobalUsings.cs | 72 ++++++++--------- .../IEndpointGroup.cs | 2 +- .../OpenTelemetryDefaults.cs | 2 +- .../ProblemDetailsExceptionHandler.cs | 2 +- .../RequestIdMiddleware.cs | 2 +- .../UserIdHttpLoggingInterceptor.cs | 2 +- .../ValidationFilter.cs | 2 +- .../WebApplicationExtensions.cs | 2 +- .../AppSettings.cs | 2 +- .../AuthenticationSettings.cs | 2 +- .../ConnectionStringSettings.cs | 2 +- .../GlobalUsings.cs | 2 +- .../GoogleAuthenticationSettings.cs | 2 +- .../JwtAuthenticationSettings.cs | 2 +- .../OptionsRegistrationExtensions.cs | 2 +- .../Entities/Project.cs | 2 +- .../Entities/ProjectMember.cs | 2 +- .../Entities/ProjectTask.cs | 2 +- .../Entities/ProjectTaskStatus.cs | 2 +- .../DependencyInjection.cs | 44 +++++----- .../GlobalUsings.cs | 2 +- .../Identity/ApplicationUser.cs | 2 +- .../Identity/GoogleAuthService.cs | 2 +- .../Persistence/ApplicationDbContext.cs | 2 +- .../Configuration/ProjectConfiguration.cs | 2 +- .../ProjectMemberConfiguration.cs | 2 +- .../Configuration/ProjectTaskConfiguration.cs | 2 +- .../Repositories/ProjectRepository.cs | 2 +- .../Features/Projects/ProjectsTests.cs | 2 +- .../GlobalUsings.cs | 2 +- .../Features/Projects/ProjectServiceTests.cs | 2 +- .../GlobalUsings.cs | 2 +- .../Common/ApiApplicationFactory.cs | 2 +- .../Common/AutoNSubstituteDataAttribute.cs | 2 +- tests/CleanApiStarter.Tests/GlobalUsings.cs | 2 +- 77 files changed, 555 insertions(+), 205 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/template.yml create mode 100644 .template.config/template.json create mode 100644 CleanApiStarter.nuspec create mode 100755 scripts/install-template.sh diff --git a/.editorconfig b/.editorconfig index d552917..f23b7d6 100644 --- a/.editorconfig +++ b/.editorconfig @@ -17,9 +17,9 @@ indent_size = 2 indent_size = 4 tab_width = 4 -# New line preferences -end_of_line = crlf -insert_final_newline = false +# New line preferences +end_of_line = lf +insert_final_newline = true #### .NET Coding Conventions #### [*.{cs,vb}] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f86f00a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,33 @@ +name: Build + +on: + pull_request: + push: + branches: + - main + +jobs: + build: + name: Build and test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore CleanApiStarter.slnx + + - name: Format + run: dotnet format CleanApiStarter.slnx --verify-no-changes --no-restore + + - name: Build + run: dotnet build CleanApiStarter.slnx --no-restore --configuration Release /nr:false -v:minimal + + - name: Test + run: dotnet test CleanApiStarter.slnx --no-build --configuration Release /nr:false -v:minimal diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..5dfba3b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,44 @@ +name: CodeQL + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: '00 0 * * 1' + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: csharp + build-mode: manual + queries: security-extended,security-and-quality + + - name: Restore + run: dotnet restore CleanApiStarter.slnx + + - name: Build + run: dotnet build CleanApiStarter.slnx --no-restore --configuration Release /nr:false -v:minimal + + - name: Analyze + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b2cf15f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Release + +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + publish: + name: Publish to NuGet.org + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + global-json-file: global.json + + - name: Setup NuGet + uses: nuget/setup-nuget@v3 + with: + nuget-version: latest + + - name: Install Mono + run: sudo apt-get update && sudo apt-get install -y mono-complete + + - name: Extract version + id: version + run: echo "value=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Update template version + run: | + jq --arg v "${{ steps.version.outputs.value }}" \ + '.symbols.caPackageVersion.defaultValue = $v' \ + .template.config/template.json > tmp.json && mv tmp.json .template.config/template.json + + - name: Update nuspec release notes + env: + RELEASE_NOTES: ${{ github.event.release.body }} + run: | + python3 - <<'PY' + import os + import xml.etree.ElementTree as ET + + ET.register_namespace('', 'http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd') + + tree = ET.parse('CleanApiStarter.nuspec') + root = tree.getroot() + ns = {'n': 'http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd'} + + root.find('.//n:releaseNotes', ns).text = os.environ.get('RELEASE_NOTES', '') + tree.write('CleanApiStarter.nuspec', xml_declaration=True, encoding='utf-8') + PY + + - name: Pack template + run: nuget pack CleanApiStarter.nuspec -NoDefaultExcludes -OutputDirectory artifacts -Version ${{ steps.version.outputs.value }} + + - name: Upload package artifact + uses: actions/upload-artifact@v7 + with: + name: nuget-package + path: artifacts/*.nupkg + + - name: Publish to NuGet + run: nuget push artifacts/*.nupkg -Source https://api.nuget.org/v3/index.json -ApiKey ${{ secrets.NUGET_API_KEY }} -SkipDuplicate diff --git a/.github/workflows/template.yml b/.github/workflows/template.yml new file mode 100644 index 0000000..a616719 --- /dev/null +++ b/.github/workflows/template.yml @@ -0,0 +1,49 @@ +name: Template + +on: + pull_request: + push: + branches: + - main + +jobs: + template: + name: Verify template output + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Setup NuGet + uses: NuGet/setup-nuget@v2 + + - name: Pack template + run: nuget pack CleanApiStarter.nuspec -NoDefaultExcludes -OutputDirectory artifacts + + - name: Install template + run: dotnet new install artifacts/CleanApiStarter.Template.0.0.0.nupkg --force + + - name: Create sample + run: dotnet new clean-api-starter -n DemoProduct -o artifacts/DemoProduct + + - name: Restore sample + working-directory: artifacts/DemoProduct + run: dotnet restore DemoProduct.slnx + + - name: Format sample + working-directory: artifacts/DemoProduct + run: dotnet format DemoProduct.slnx --verify-no-changes --no-restore + + - name: Build sample + working-directory: artifacts/DemoProduct + run: dotnet build DemoProduct.slnx --no-restore --configuration Release /nr:false -v:minimal + + - name: Test sample + working-directory: artifacts/DemoProduct + run: dotnet test DemoProduct.slnx --no-build --configuration Release /nr:false -v:minimal diff --git a/.template.config/template.json b/.template.config/template.json new file mode 100644 index 0000000..41c20a6 --- /dev/null +++ b/.template.config/template.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Chathuranga", + "classifications": [ + "Web", + "API", + "Clean Architecture", + "Aspire", + "PostgreSQL", + "OpenTelemetry" + ], + "identity": "CleanApiStarter.Template", + "name": "Clean API Starter", + "shortName": "clean-api-starter", + "sourceName": "CleanApiStarter", + "preferNameDirectory": true, + "symbols": { + "caPackageVersion": { + "type": "parameter", + "datatype": "text", + "defaultValue": "0.0.0", + "replaces": "0.0.0" + } + }, + "tags": { + "language": "C#", + "type": "solution" + }, + "sources": [ + { + "modifiers": [ + { + "exclude": [ + ".git/**", + ".idea/**", + ".junie/**", + ".vs/**", + "**/.DS_Store", + "**/bin/**", + "**/obj/**", + "artifacts/**", + ".github/workflows/release.yml", + ".github/workflows/template.yml", + "CleanApiStarter.nuspec", + "scripts/install-template.sh" + ] + } + ] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 4b1a93e..463b48f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,16 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - Keep `PackageVersion` items sorted alphabetically by `Include`. - Do not add package versions directly in individual `.csproj` files. +## Template Packaging + +- This repository is also the `dotnet new` template source. +- Keep template metadata in `.template.config/template.json`. +- Keep NuGet template package metadata in `CleanApiStarter.nuspec`. +- Use `scripts/install-template.sh` to pack and install the local template. +- Keep repo-only template packaging scripts excluded from generated template output. +- Keep CodeQL security scanning in `.github/workflows/codeql.yml`, and allow generated projects to inherit it. +- Keep release publishing triggered by GitHub Release publication with `vX.Y.Z` tags, not manual version inputs. + ## Testing - Use xUnit v3, AutoFixture.xUnit3, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly for unit tests. @@ -163,7 +173,7 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - After structural or package changes, run: ```bash -dotnet restore CleanApiStarter.slnx --disable-parallel +dotnet restore CleanApiStarter.slnx dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal ``` diff --git a/CleanApiStarter.nuspec b/CleanApiStarter.nuspec new file mode 100644 index 0000000..32469d2 --- /dev/null +++ b/CleanApiStarter.nuspec @@ -0,0 +1,24 @@ + + + + CleanApiStarter.Template + 0.0.0 + Clean API Starter Template + Chathuranga + Clean Architecture API starter template with .NET, Aspire, PostgreSQL, OpenTelemetry, Scalar, JWT authentication, EF Core, and tests. + Clean Architecture API starter template with .NET, Aspire, PostgreSQL, OpenTelemetry, Scalar, JWT authentication, EF Core, and tests. + + LICENSE + README.md + false + + + + dotnet-new template aspnetcore api clean-architecture aspire postgresql opentelemetry scalar jwt efcore + + + + + + + diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx index 9c7a5fa..81a65eb 100644 --- a/CleanApiStarter.slnx +++ b/CleanApiStarter.slnx @@ -2,11 +2,18 @@ + + + + + + + @@ -25,6 +32,10 @@ + + + + diff --git a/README.md b/README.md index 482e97f..c73e07d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The project is intentionally both a template and a working reference application - xUnit v3 unit tests with AutoFixture, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly. - MSTest API integration tests with Testcontainers for PostgreSQL. - Local coverage script that generates an HTML report with ReportGenerator. +- GitHub Actions CI with build, test, template verification, and CodeQL security scanning. ## Solution Layout @@ -76,6 +77,41 @@ The SDK is pinned in `global.json`: } ``` +## Use As A Template + +Install the template package from NuGet: + +```bash +dotnet new install CleanApiStarter.Template +``` + +Create a new solution: + +```bash +dotnet new clean-api-starter -n MyProduct +``` + +The template replaces `CleanApiStarter` in solution, project, file, and namespace names. For example, `CleanApiStarter.Api` becomes `MyProduct.Api`. + +While developing the template locally, run: + +```bash +scripts/install-template.sh +``` + +That script: + +- uninstalls the previous local template package +- packs the current repo with `CleanApiStarter.nuspec` +- installs the generated local `.nupkg` +- deletes the temporary package from `artifacts` + +Then create a local test solution: + +```bash +dotnet new clean-api-starter -n DemoProduct +``` + ## Run With Aspire ```bash @@ -474,10 +510,19 @@ artifacts/coverage/report/index.html Restore and build: ```bash -dotnet restore CleanApiStarter.slnx --disable-parallel +dotnet restore CleanApiStarter.slnx dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal ``` +## CI + +GitHub Actions workflows live in `.github/workflows`: + +- `build.yml` restores, builds, and tests the repository on pull requests and pushes to `main`. +- `codeql.yml` runs CodeQL static security analysis on pull requests, pushes to `main`, and weekly on Monday. +- `template.yml` packs the template, installs it locally, creates a sample solution, then restores, builds, and tests the generated output. +- `release.yml` publishes the template to NuGet when a GitHub Release is published with a tag such as `v1.0.0`. + ## Package Management Package versions are managed centrally in: diff --git a/scripts/install-template.sh b/scripts/install-template.sh new file mode 100755 index 0000000..01aa371 --- /dev/null +++ b/scripts/install-template.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +name="CleanApiStarter.Template.0.0.0.nupkg" +pkg="$root/artifacts/$name" + +cd "$root" + +dotnet new uninstall CleanApiStarter.Template 2>/dev/null || true +nuget pack "CleanApiStarter.nuspec" -NoDefaultExcludes -OutputDirectory "artifacts" +dotnet new install "$pkg" --force +rm -f "$pkg" diff --git a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs index a36d7cd..516c2a1 100644 --- a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs +++ b/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs @@ -97,4 +97,4 @@ async function copyAccessToken() { return app; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs b/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs index 13fa398..dc064cb 100644 --- a/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs +++ b/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs @@ -36,4 +36,4 @@ private static async Task GetCurrentUser( return Results.Ok(currentUser); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs b/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs index b5c74a0..c55c4a9 100644 --- a/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs +++ b/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs @@ -173,4 +173,4 @@ private static async Task DeleteTask( return deleted ? Results.NoContent() : Results.NotFound(); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs b/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs index f4e8e85..a0736b0 100644 --- a/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs +++ b/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs @@ -32,4 +32,4 @@ private static async Task GetProjects( projects.HasNextPage }); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs index db4fb8f..9f9d081 100644 --- a/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -22,4 +22,4 @@ global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Routing; -global using Scalar.AspNetCore; \ No newline at end of file +global using Scalar.AspNetCore; diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs index 452e9af..4b435c6 100644 --- a/src/CleanApiStarter.Api/Program.cs +++ b/src/CleanApiStarter.Api/Program.cs @@ -1,40 +1,40 @@ -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.AddAspNetCoreDefaults(); - -builder.Services.AddAppSettings(builder.Configuration); -builder.Services.AddApplication(); -builder.Services.AddInfrastructure(); -builder.Services.AddScoped(); - -WebApplication app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi() - .WithDocumentPerVersion(); - - app.MapScalarApiReference(options => - { - IReadOnlyList descriptions = app.DescribeApiVersions(); - - for (int index = 0; index < descriptions.Count; index++) - { - ApiVersionDescription description = descriptions[index]; - bool isDefault = index == descriptions.Count - 1; - - options.AddDocument(description.GroupName, description.GroupName, isDefault: isDefault); - } - }); -} - -app.UseAspNetCoreDefaults(); -app.UseHttpsRedirection(); -app.MapGoogleLoginPage(); -app.MapEndpoints(Assembly.GetExecutingAssembly()); -app.MapDefaultEndpoints(); - -app.Run(); - -public partial class Program; \ No newline at end of file +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.AddAspNetCoreDefaults(); + +builder.Services.AddAppSettings(builder.Configuration); +builder.Services.AddApplication(); +builder.Services.AddInfrastructure(); +builder.Services.AddScoped(); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi() + .WithDocumentPerVersion(); + + app.MapScalarApiReference(options => + { + IReadOnlyList descriptions = app.DescribeApiVersions(); + + for (int index = 0; index < descriptions.Count; index++) + { + ApiVersionDescription description = descriptions[index]; + bool isDefault = index == descriptions.Count - 1; + + options.AddDocument(description.GroupName, description.GroupName, isDefault: isDefault); + } + }); +} + +app.UseAspNetCoreDefaults(); +app.UseHttpsRedirection(); +app.MapGoogleLoginPage(); +app.MapEndpoints(Assembly.GetExecutingAssembly()); +app.MapDefaultEndpoints(); + +app.Run(); + +public partial class Program; diff --git a/src/CleanApiStarter.Api/Services/CurrentUser.cs b/src/CleanApiStarter.Api/Services/CurrentUser.cs index c48f384..37c0828 100644 --- a/src/CleanApiStarter.Api/Services/CurrentUser.cs +++ b/src/CleanApiStarter.Api/Services/CurrentUser.cs @@ -6,4 +6,4 @@ public class CurrentUser(IHttpContextAccessor httpContextAccessor) : IUser public List? Roles => httpContextAccessor.HttpContext?.User.FindAll(ClaimTypes.Role).Select(x => x.Value).ToList(); -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.AppHost/Program.cs b/src/CleanApiStarter.AppHost/Program.cs index aa0a117..a5848d6 100644 --- a/src/CleanApiStarter.AppHost/Program.cs +++ b/src/CleanApiStarter.AppHost/Program.cs @@ -1,19 +1,19 @@ -IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); - -string databasePath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "database")); -string databaseMigrationsPath = Path.Combine(databasePath, "migrations"); - -IResourceBuilder postgresServer = builder.AddPostgres("postgres-server") - .WithImageTag("latest") - .WithVolume("clean-api-starter-postgres-data", "/var/lib/postgresql") - .WithInitFiles(databaseMigrationsPath) - .WithPgAdmin(); - -IResourceBuilder database = postgresServer.AddDatabase("postgres") - .WithCreationScript("SELECT 1;"); - -builder.AddProject("api") - .WithReference(database) - .WaitFor(database); - -builder.Build().Run(); \ No newline at end of file +IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args); + +string databasePath = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, "..", "..", "database")); +string databaseMigrationsPath = Path.Combine(databasePath, "migrations"); + +IResourceBuilder postgresServer = builder.AddPostgres("postgres-server") + .WithImageTag("latest") + .WithVolume("clean-api-starter-postgres-data", "/var/lib/postgresql") + .WithInitFiles(databaseMigrationsPath) + .WithPgAdmin(); + +IResourceBuilder database = postgresServer.AddDatabase("postgres") + .WithCreationScript("SELECT 1;"); + +builder.AddProject("api") + .WithReference(database) + .WaitFor(database); + +builder.Build().Run(); diff --git a/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs b/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs index 00c53cc..5064363 100644 --- a/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs +++ b/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs @@ -4,4 +4,4 @@ public interface IUser { string? Id { get; } List? Roles { get; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs b/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs index a75bfe8..3e99c59 100644 --- a/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs +++ b/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs @@ -5,4 +5,4 @@ public sealed class ArrayResult public required IReadOnlyCollection Items { get; init; } public int Count => Items.Count; -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs b/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs index eb30234..ca80f8a 100644 --- a/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs +++ b/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs @@ -5,4 +5,4 @@ public sealed class PaginatedQuery public int Limit { get; set; } = 20; public int Offset { get; set; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs b/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs index fe83865..8db6a25 100644 --- a/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs +++ b/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs @@ -10,4 +10,4 @@ public PaginatedQueryValidator() RuleFor(query => query.Offset) .GreaterThanOrEqualTo(0); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs b/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs index 83f9be0..c1259db 100644 --- a/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs +++ b/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs @@ -24,4 +24,4 @@ public PaginatedResult Map(Func map TotalCount = TotalCount }; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/DependencyInjection.cs b/src/CleanApiStarter.Application/DependencyInjection.cs index 54e6f72..94651fd 100644 --- a/src/CleanApiStarter.Application/DependencyInjection.cs +++ b/src/CleanApiStarter.Application/DependencyInjection.cs @@ -1,12 +1,12 @@ -namespace CleanApiStarter.Application; - -public static class DependencyInjection -{ - public static IServiceCollection AddApplication(this IServiceCollection services) - { - services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); - services.AddScoped(); - - return services; - } -} \ No newline at end of file +namespace CleanApiStarter.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); + services.AddScoped(); + + return services; + } +} diff --git a/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs b/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs index 95156bf..d115f40 100644 --- a/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs +++ b/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs @@ -5,4 +5,4 @@ public sealed class AuthTokenDto public required string AccessToken { get; init; } public required DateTimeOffset ExpiresAt { get; init; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs b/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs index e52f126..f64b9d1 100644 --- a/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs +++ b/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs @@ -9,4 +9,4 @@ public sealed class CurrentUserDto public required string Name { get; init; } public IReadOnlyCollection Roles { get; init; } = []; -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs index c5d9da9..b1ebed4 100644 --- a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs +++ b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs @@ -3,4 +3,4 @@ namespace CleanApiStarter.Application.Features.Auth; public sealed class GoogleSignInDto { public required string IdToken { get; init; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs index 4f52587..7ef17c3 100644 --- a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs +++ b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs @@ -7,4 +7,4 @@ public GoogleSignInDtoValidator() RuleFor(signIn => signIn.IdToken) .NotEmpty(); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs b/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs index 27bcb46..1e1fc6a 100644 --- a/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs +++ b/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs @@ -5,4 +5,4 @@ public interface IAuthService Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken); Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken); -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs index aee321f..fb524b1 100644 --- a/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs +++ b/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs @@ -11,4 +11,4 @@ public CreateProjectDtoValidator() RuleFor(project => project.Description) .MaximumLength(2_000); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs index 91bac41..c06a35f 100644 --- a/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs +++ b/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs @@ -16,4 +16,4 @@ public CreateProjectTaskDtoValidator() .When(task => task.DueDate.HasValue) .WithMessage("Due date must be in the future."); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs b/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs index d0b627b..8f2f054 100644 --- a/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs +++ b/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs @@ -32,4 +32,4 @@ Task> GetTasksAsync( Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); Task SaveChangesAsync(CancellationToken cancellationToken); -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs b/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs index cc6f1c9..ff0a3c6 100644 --- a/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs +++ b/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs @@ -29,4 +29,4 @@ Task UpdateTaskAsync( Task CompleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs index 640b8bd..c8d8356 100644 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs +++ b/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs @@ -18,4 +18,4 @@ public sealed class CreateProjectDto public required string Name { get; init; } public required string Description { get; init; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs index d441eae..2f4dfae 100644 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs +++ b/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs @@ -12,4 +12,4 @@ public enum ProjectTaskMutationResult Success, NotFound, AlreadyCompleted -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs index ebcc417..9d162bd 100644 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs +++ b/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs @@ -245,4 +245,4 @@ private static ProjectTaskDto MapTask(ProjectTask task) CompletedAt = task.CompletedAt }; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs index bd1803b..03f71fa 100644 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs +++ b/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs @@ -37,4 +37,4 @@ public sealed class UpdateProjectTaskDto public required ProjectTaskStatus Status { get; init; } public DateTime? DueDate { get; init; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs index 6b00b52..0227274 100644 --- a/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs +++ b/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs @@ -21,4 +21,4 @@ public UpdateProjectTaskDtoValidator() .When(task => task.DueDate.HasValue && task.Status != ProjectTaskStatus.Done) .WithMessage("Due date must be in the future."); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/src/CleanApiStarter.Application/GlobalUsings.cs index 72d832e..a886fb3 100644 --- a/src/CleanApiStarter.Application/GlobalUsings.cs +++ b/src/CleanApiStarter.Application/GlobalUsings.cs @@ -1,11 +1,11 @@ -global using System.Security.Claims; - -global using CleanApiStarter.Application.Common.Interfaces; -global using CleanApiStarter.Application.Common.Models; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.Domain.Entities; - -global using FluentValidation; - -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Logging; \ No newline at end of file +global using System.Security.Claims; + +global using CleanApiStarter.Application.Common.Interfaces; +global using CleanApiStarter.Application.Common.Models; +global using CleanApiStarter.Application.Features.Projects; +global using CleanApiStarter.Domain.Entities; + +global using FluentValidation; + +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; diff --git a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs index a70143d..2e7e5bc 100644 --- a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs +++ b/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs @@ -113,4 +113,4 @@ private static void AddServiceDiscoveryDefaults(this IHostApplicationBuilder bui http.AddServiceDiscovery(); }); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/src/CleanApiStarter.AspNetCore/Extensions.cs index 7394f11..3f223ad 100644 --- a/src/CleanApiStarter.AspNetCore/Extensions.cs +++ b/src/CleanApiStarter.AspNetCore/Extensions.cs @@ -65,4 +65,4 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) return app; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs index a0aabf7..0f611ae 100644 --- a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs +++ b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs @@ -1,36 +1,36 @@ -global using System.Diagnostics; -global using System.IdentityModel.Tokens.Jwt; -global using System.IO.Compression; -global using System.Reflection; -global using System.Security.Claims; -global using System.Text; - -global using Asp.Versioning; -global using Asp.Versioning.ApiExplorer; -global using Asp.Versioning.Builder; - -global using CleanApiStarter.Configuration; - -global using FluentValidation; -global using FluentValidation.Results; - -global using Microsoft.AspNetCore.Authentication.JwtBearer; -global using Microsoft.AspNetCore.Builder; -global using Microsoft.AspNetCore.Diagnostics; -global using Microsoft.AspNetCore.Diagnostics.HealthChecks; -global using Microsoft.AspNetCore.Http; -global using Microsoft.AspNetCore.HttpLogging; -global using Microsoft.AspNetCore.Mvc; -global using Microsoft.AspNetCore.ResponseCompression; -global using Microsoft.AspNetCore.Routing; -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Diagnostics.HealthChecks; -global using Microsoft.Extensions.Hosting; -global using Microsoft.Extensions.Logging; -global using Microsoft.IdentityModel.Tokens; - -global using Npgsql; - -global using OpenTelemetry.Logs; -global using OpenTelemetry.Metrics; -global using OpenTelemetry.Trace; \ No newline at end of file +global using System.Diagnostics; +global using System.IdentityModel.Tokens.Jwt; +global using System.IO.Compression; +global using System.Reflection; +global using System.Security.Claims; +global using System.Text; + +global using Asp.Versioning; +global using Asp.Versioning.ApiExplorer; +global using Asp.Versioning.Builder; + +global using CleanApiStarter.Configuration; + +global using FluentValidation; +global using FluentValidation.Results; + +global using Microsoft.AspNetCore.Authentication.JwtBearer; +global using Microsoft.AspNetCore.Builder; +global using Microsoft.AspNetCore.Diagnostics; +global using Microsoft.AspNetCore.Diagnostics.HealthChecks; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.HttpLogging; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.AspNetCore.ResponseCompression; +global using Microsoft.AspNetCore.Routing; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Diagnostics.HealthChecks; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Microsoft.IdentityModel.Tokens; + +global using Npgsql; + +global using OpenTelemetry.Logs; +global using OpenTelemetry.Metrics; +global using OpenTelemetry.Trace; diff --git a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs b/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs index cc4f2e9..66a2135 100644 --- a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs +++ b/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs @@ -7,4 +7,4 @@ public interface IEndpointGroup static virtual string? RoutePrefix => null; static abstract void Map(RouteGroupBuilder groupBuilder); -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs b/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs index 154ef68..70733a2 100644 --- a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs +++ b/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs @@ -38,4 +38,4 @@ private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicat return builder; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs index 4f270a9..fe50223 100644 --- a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs +++ b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs @@ -69,4 +69,4 @@ public async ValueTask TryHandleAsync( return true; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs b/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs index 0072cb2..003a9ef 100644 --- a/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs +++ b/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs @@ -20,4 +20,4 @@ private static string GetRequestId(HttpContext context) { return Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs b/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs index 4e8fff7..81c7243 100644 --- a/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs +++ b/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs @@ -25,4 +25,4 @@ private static string GetUserId(HttpContext httpContext) ?? httpContext.User.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? AnonymousUserId; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.AspNetCore/ValidationFilter.cs b/src/CleanApiStarter.AspNetCore/ValidationFilter.cs index ecaf242..63b2cc0 100644 --- a/src/CleanApiStarter.AspNetCore/ValidationFilter.cs +++ b/src/CleanApiStarter.AspNetCore/ValidationFilter.cs @@ -42,4 +42,4 @@ public sealed class ValidationFilter : IEndpointFilter return Results.ValidationProblem(errors, statusCode: StatusCodes.Status422UnprocessableEntity); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs index e6a6a58..87e5166 100644 --- a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs +++ b/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs @@ -37,4 +37,4 @@ private static ApiVersion GetApiVersion(Type endpointGroupType) return new ApiVersion(majorVersion); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Configuration/AppSettings.cs b/src/CleanApiStarter.Configuration/AppSettings.cs index a0f32ea..19cdf16 100644 --- a/src/CleanApiStarter.Configuration/AppSettings.cs +++ b/src/CleanApiStarter.Configuration/AppSettings.cs @@ -9,4 +9,4 @@ public sealed class AppSettings [Required] [ValidateObjectMembers] public AuthenticationSettings Authentication { get; set; } = new(); -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Configuration/AuthenticationSettings.cs b/src/CleanApiStarter.Configuration/AuthenticationSettings.cs index ff38cd4..24f76fa 100644 --- a/src/CleanApiStarter.Configuration/AuthenticationSettings.cs +++ b/src/CleanApiStarter.Configuration/AuthenticationSettings.cs @@ -9,4 +9,4 @@ public sealed class AuthenticationSettings [Required] [ValidateObjectMembers] public JwtAuthenticationSettings Jwt { get; set; } = new(); -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs b/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs index deb6fbe..1299016 100644 --- a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs +++ b/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs @@ -4,4 +4,4 @@ public sealed class ConnectionStringSettings { [Required] public string Postgres { get; set; } = string.Empty; -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Configuration/GlobalUsings.cs b/src/CleanApiStarter.Configuration/GlobalUsings.cs index 1fd17fc..a2250c9 100644 --- a/src/CleanApiStarter.Configuration/GlobalUsings.cs +++ b/src/CleanApiStarter.Configuration/GlobalUsings.cs @@ -2,4 +2,4 @@ global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Options; \ No newline at end of file +global using Microsoft.Extensions.Options; diff --git a/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs b/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs index 1fda7db..b6f8aec 100644 --- a/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs +++ b/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs @@ -4,4 +4,4 @@ public sealed class GoogleAuthenticationSettings { [Required] public string ClientId { get; set; } = string.Empty; -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs b/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs index 9183996..083c6d8 100644 --- a/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs +++ b/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs @@ -14,4 +14,4 @@ public sealed class JwtAuthenticationSettings [Range(1, 1440)] public int ExpirationMinutes { get; set; } = 60; -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs b/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs index 051f517..6b767c7 100644 --- a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs +++ b/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs @@ -16,4 +16,4 @@ public static IServiceCollection AddAppSettings( return services; } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Domain/Entities/Project.cs b/src/CleanApiStarter.Domain/Entities/Project.cs index a9073d7..37555ff 100644 --- a/src/CleanApiStarter.Domain/Entities/Project.cs +++ b/src/CleanApiStarter.Domain/Entities/Project.cs @@ -15,4 +15,4 @@ public sealed class Project public List Members { get; init; } = []; public List Tasks { get; init; } = []; -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Domain/Entities/ProjectMember.cs b/src/CleanApiStarter.Domain/Entities/ProjectMember.cs index d9ec077..c55ef9f 100644 --- a/src/CleanApiStarter.Domain/Entities/ProjectMember.cs +++ b/src/CleanApiStarter.Domain/Entities/ProjectMember.cs @@ -9,4 +9,4 @@ public sealed class ProjectMember public required DateTime CreatedAt { get; init; } public Project Project { get; init; } = null!; -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Domain/Entities/ProjectTask.cs b/src/CleanApiStarter.Domain/Entities/ProjectTask.cs index ae8cc3e..c12e61c 100644 --- a/src/CleanApiStarter.Domain/Entities/ProjectTask.cs +++ b/src/CleanApiStarter.Domain/Entities/ProjectTask.cs @@ -19,4 +19,4 @@ public sealed class ProjectTask public DateTime? CompletedAt { get; set; } public Project Project { get; init; } = null!; -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs b/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs index 8a1a987..f553dc5 100644 --- a/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs +++ b/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs @@ -5,4 +5,4 @@ public enum ProjectTaskStatus Todo = 0, InProgress = 1, Done = 2 -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs index fca1035..12dcd54 100644 --- a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs +++ b/src/CleanApiStarter.Infrastructure/DependencyInjection.cs @@ -1,22 +1,22 @@ -namespace CleanApiStarter.Infrastructure; - -public static class DependencyInjection -{ - public static IServiceCollection AddInfrastructure(this IServiceCollection services) - { - services.AddDbContext((serviceProvider, options) => - { - AppSettings appSettings = serviceProvider.GetRequiredService(); - options.UseNpgsql(appSettings.ConnectionStrings.Postgres); - }); - - services.AddIdentityCore() - .AddRoles() - .AddEntityFrameworkStores(); - - services.AddScoped(); - services.AddScoped(); - - return services; - } -} \ No newline at end of file +namespace CleanApiStarter.Infrastructure; + +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services) + { + services.AddDbContext((serviceProvider, options) => + { + AppSettings appSettings = serviceProvider.GetRequiredService(); + options.UseNpgsql(appSettings.ConnectionStrings.Postgres); + }); + + services.AddIdentityCore() + .AddRoles() + .AddEntityFrameworkStores(); + + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs index f260b69..8801542 100644 --- a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs +++ b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs @@ -18,4 +18,4 @@ global using Microsoft.EntityFrameworkCore; global using Microsoft.EntityFrameworkCore.Metadata.Builders; global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.IdentityModel.Tokens; \ No newline at end of file +global using Microsoft.IdentityModel.Tokens; diff --git a/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs b/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs index 2996a8e..c801b9f 100644 --- a/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs +++ b/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs @@ -1,3 +1,3 @@ namespace CleanApiStarter.Infrastructure.Identity; -public sealed class ApplicationUser : IdentityUser; \ No newline at end of file +public sealed class ApplicationUser : IdentityUser; diff --git a/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs b/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs index d6e61ce..367a101 100644 --- a/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs +++ b/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs @@ -148,4 +148,4 @@ private static void ThrowIfFailed(IdentityResult result) string errors = string.Join("; ", result.Errors.Select(error => error.Description)); throw new InvalidOperationException(errors); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs b/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs index 85b954f..d0e5c7e 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs @@ -15,4 +15,4 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs index 566888a..b919bb6 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs +++ b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs @@ -28,4 +28,4 @@ public void Configure(EntityTypeBuilder builder) builder.Property(project => project.CreatedAt) .HasColumnName("created_at"); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs index 3816dca..f1a3cf1 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs +++ b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs @@ -27,4 +27,4 @@ public void Configure(EntityTypeBuilder builder) .HasForeignKey(member => member.ProjectId) .OnDelete(DeleteBehavior.Cascade); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs index 4382072..aa0dfb5 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs +++ b/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs @@ -43,4 +43,4 @@ public void Configure(EntityTypeBuilder builder) .HasForeignKey(task => task.ProjectId) .OnDelete(DeleteBehavior.Cascade); } -} \ No newline at end of file +} diff --git a/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs b/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs index 1b7a196..04563a4 100644 --- a/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs +++ b/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs @@ -155,4 +155,4 @@ private IQueryable ProjectTasksForMember(Guid projectId, string use .Where(task => task.ProjectId == projectId) .Where(task => task.Project.Members.Any(member => member.UserId == userId)); } -} \ No newline at end of file +} diff --git a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs b/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs index 253ff64..eebc218 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs +++ b/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs @@ -52,4 +52,4 @@ public async Task Projects_PostAuthenticatedRequest_CreatesProject() } public TestContext TestContext { get; set; } -} \ No newline at end of file +} diff --git a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs b/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs index 1435a22..3fa1cfb 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs +++ b/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs @@ -6,4 +6,4 @@ global using Microsoft.VisualStudio.TestTools.UnitTesting; -global using Shouldly; \ No newline at end of file +global using Shouldly; diff --git a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs b/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs index fd03231..73c9e68 100644 --- a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs +++ b/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs @@ -37,4 +37,4 @@ public async Task CompleteTaskAsync_TaskIsAlreadyCompleted_ReturnsAlreadyComplet result.ShouldBe(ProjectTaskMutationResult.AlreadyCompleted); _ = projectRepository.DidNotReceive().SaveChangesAsync(Arg.Any()); } -} \ No newline at end of file +} diff --git a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs b/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs index 6c472a4..02c2906 100644 --- a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs +++ b/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs @@ -11,4 +11,4 @@ global using Shouldly; -global using Xunit; \ No newline at end of file +global using Xunit; diff --git a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs b/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs index 1277724..feab947 100644 --- a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs +++ b/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs @@ -97,4 +97,4 @@ private static IEnumerable GetDatabaseScriptPaths() throw new DirectoryNotFoundException("Could not find database/migrations from the test output directory."); } -} \ No newline at end of file +} diff --git a/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs b/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs index 09eb258..1ff103c 100644 --- a/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs +++ b/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs @@ -10,4 +10,4 @@ private static IFixture CreateFixture() return fixture; } -} \ No newline at end of file +} diff --git a/tests/CleanApiStarter.Tests/GlobalUsings.cs b/tests/CleanApiStarter.Tests/GlobalUsings.cs index c425319..5aa93fe 100644 --- a/tests/CleanApiStarter.Tests/GlobalUsings.cs +++ b/tests/CleanApiStarter.Tests/GlobalUsings.cs @@ -13,4 +13,4 @@ global using Npgsql; -global using Testcontainers.PostgreSql; \ No newline at end of file +global using Testcontainers.PostgreSql; From 1feb398dc23e377323bcbb2b883394a4635892a7 Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Mon, 4 May 2026 08:05:06 +0800 Subject: [PATCH 11/33] refactor template packaging; replace nuspec with csproj for NuGet packaging and update related scripts and documentation --- .github/workflows/release.yml | 30 +++--------------------------- .github/workflows/template.yml | 9 +++------ .template.config/template.json | 2 +- AGENTS.md | 3 ++- CleanApiStarter.Template.csproj | 31 +++++++++++++++++++++++++++++++ CleanApiStarter.nuspec | 24 ------------------------ CleanApiStarter.slnx | 2 +- README.md | 2 +- scripts/install-template.sh | 2 +- 9 files changed, 43 insertions(+), 62 deletions(-) create mode 100644 CleanApiStarter.Template.csproj delete mode 100644 CleanApiStarter.nuspec diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b2cf15f..0715734 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,14 +21,6 @@ jobs: with: global-json-file: global.json - - name: Setup NuGet - uses: nuget/setup-nuget@v3 - with: - nuget-version: latest - - - name: Install Mono - run: sudo apt-get update && sudo apt-get install -y mono-complete - - name: Extract version id: version run: echo "value=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT @@ -39,26 +31,10 @@ jobs: '.symbols.caPackageVersion.defaultValue = $v' \ .template.config/template.json > tmp.json && mv tmp.json .template.config/template.json - - name: Update nuspec release notes + - name: Pack template env: RELEASE_NOTES: ${{ github.event.release.body }} - run: | - python3 - <<'PY' - import os - import xml.etree.ElementTree as ET - - ET.register_namespace('', 'http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd') - - tree = ET.parse('CleanApiStarter.nuspec') - root = tree.getroot() - ns = {'n': 'http://schemas.microsoft.com/packaging/2012/06/nuspec.xsd'} - - root.find('.//n:releaseNotes', ns).text = os.environ.get('RELEASE_NOTES', '') - tree.write('CleanApiStarter.nuspec', xml_declaration=True, encoding='utf-8') - PY - - - name: Pack template - run: nuget pack CleanApiStarter.nuspec -NoDefaultExcludes -OutputDirectory artifacts -Version ${{ steps.version.outputs.value }} + run: dotnet pack CleanApiStarter.Template.csproj --configuration Release --output artifacts -p:PackageVersion=${{ steps.version.outputs.value }} -p:PackageReleaseNotes="$RELEASE_NOTES" - name: Upload package artifact uses: actions/upload-artifact@v7 @@ -67,4 +43,4 @@ jobs: path: artifacts/*.nupkg - name: Publish to NuGet - run: nuget push artifacts/*.nupkg -Source https://api.nuget.org/v3/index.json -ApiKey ${{ secrets.NUGET_API_KEY }} -SkipDuplicate + run: dotnet nuget push artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} --skip-duplicate diff --git a/.github/workflows/template.yml b/.github/workflows/template.yml index a616719..981cc62 100644 --- a/.github/workflows/template.yml +++ b/.github/workflows/template.yml @@ -13,18 +13,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json - - name: Setup NuGet - uses: NuGet/setup-nuget@v2 - - name: Pack template - run: nuget pack CleanApiStarter.nuspec -NoDefaultExcludes -OutputDirectory artifacts + run: dotnet pack CleanApiStarter.Template.csproj --configuration Release --output artifacts - name: Install template run: dotnet new install artifacts/CleanApiStarter.Template.0.0.0.nupkg --force diff --git a/.template.config/template.json b/.template.config/template.json index 41c20a6..e23e59b 100644 --- a/.template.config/template.json +++ b/.template.config/template.json @@ -41,7 +41,7 @@ "artifacts/**", ".github/workflows/release.yml", ".github/workflows/template.yml", - "CleanApiStarter.nuspec", + "CleanApiStarter.Template.csproj", "scripts/install-template.sh" ] } diff --git a/AGENTS.md b/AGENTS.md index 463b48f..e92b9af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,8 @@ This repository is a Clean Architecture API starter template named `CleanApiStar - This repository is also the `dotnet new` template source. - Keep template metadata in `.template.config/template.json`. -- Keep NuGet template package metadata in `CleanApiStarter.nuspec`. +- Keep NuGet template package metadata in `CleanApiStarter.Template.csproj`. +- Use `dotnet pack` and `dotnet nuget push` for template packaging and publishing. Do not use `nuget pack`, `nuget.exe`, or Mono. - Use `scripts/install-template.sh` to pack and install the local template. - Keep repo-only template packaging scripts excluded from generated template output. - Keep CodeQL security scanning in `.github/workflows/codeql.yml`, and allow generated projects to inherit it. diff --git a/CleanApiStarter.Template.csproj b/CleanApiStarter.Template.csproj new file mode 100644 index 0000000..f3497b3 --- /dev/null +++ b/CleanApiStarter.Template.csproj @@ -0,0 +1,31 @@ + + + net10.0 + false + true + false + true + true + $(NoWarn);NU5128 + + CleanApiStarter.Template + 0.0.0 + Clean API Starter Template + Chathuranga + Clean Architecture API starter template with .NET, Aspire, PostgreSQL, OpenTelemetry, Scalar, JWT authentication, EF Core, and tests. + dotnet-new;template;aspnetcore;api;clean-architecture;aspire;postgresql;opentelemetry;scalar;jwt;efcore + Template + LICENSE + README.md + + + + + + + + diff --git a/CleanApiStarter.nuspec b/CleanApiStarter.nuspec deleted file mode 100644 index 32469d2..0000000 --- a/CleanApiStarter.nuspec +++ /dev/null @@ -1,24 +0,0 @@ - - - - CleanApiStarter.Template - 0.0.0 - Clean API Starter Template - Chathuranga - Clean Architecture API starter template with .NET, Aspire, PostgreSQL, OpenTelemetry, Scalar, JWT authentication, EF Core, and tests. - Clean Architecture API starter template with .NET, Aspire, PostgreSQL, OpenTelemetry, Scalar, JWT authentication, EF Core, and tests. - - LICENSE - README.md - false - - - - dotnet-new template aspnetcore api clean-architecture aspire postgresql opentelemetry scalar jwt efcore - - - - - - - diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx index 81a65eb..acb877a 100644 --- a/CleanApiStarter.slnx +++ b/CleanApiStarter.slnx @@ -8,7 +8,7 @@ - + diff --git a/README.md b/README.md index c73e07d..d1678e0 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ scripts/install-template.sh That script: - uninstalls the previous local template package -- packs the current repo with `CleanApiStarter.nuspec` +- packs the current repo with `CleanApiStarter.Template.csproj` - installs the generated local `.nupkg` - deletes the temporary package from `artifacts` diff --git a/scripts/install-template.sh b/scripts/install-template.sh index 01aa371..e41dcbe 100755 --- a/scripts/install-template.sh +++ b/scripts/install-template.sh @@ -8,6 +8,6 @@ pkg="$root/artifacts/$name" cd "$root" dotnet new uninstall CleanApiStarter.Template 2>/dev/null || true -nuget pack "CleanApiStarter.nuspec" -NoDefaultExcludes -OutputDirectory "artifacts" +dotnet pack "CleanApiStarter.Template.csproj" --configuration Release --output "artifacts" dotnet new install "$pkg" --force rm -f "$pkg" From 8689e08791707a277a32645a25a2367c4c651241 Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Wed, 6 May 2026 19:34:41 +0800 Subject: [PATCH 12/33] update .gitignore to exclude .DS_Store files --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index a3c8cb2..0e2fcbc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +.DS_Store + # User-specific files *.rsuser *.suo @@ -400,3 +402,4 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +.DS_Store From 610d1bc170f84673654bcd2dccf3ef887be21676 Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:13:02 +0800 Subject: [PATCH 13/33] refactor integration tests to use xUnit; update PostgreSQL container version and improve error handling in ProblemDetailsExceptionHandler --- .github/dependabot.yml | 16 ++++ .template.config/template.json | 1 + CONTRIBUTING.md | 84 +++++++++++++++++++ Directory.Packages.props | 49 ++++++----- README.md | 4 +- ...V001__create_projects_and_tasks_tables.sql | 10 +-- scripts/test-coverage.sh | 15 +++- .../CleanApiStarter.Api.csproj | 7 -- src/CleanApiStarter.Api/GlobalUsings.cs | 4 - src/CleanApiStarter.Api/Program.cs | 22 +---- src/CleanApiStarter.AppHost/Program.cs | 2 +- .../CleanApiStarter.AspNetCore.csproj | 2 + src/CleanApiStarter.AspNetCore/Extensions.cs | 23 +++-- .../GlobalUsings.cs | 2 + .../OpenApiDocumentationExtensions.cs | 32 +++++++ .../ProblemDetailsExceptionHandler.cs | 34 +++++--- ...leanApiStarter.Api.IntegrationTests.csproj | 8 +- .../Features/Projects/ProjectsTests.cs | 33 ++------ .../GlobalUsings.cs | 4 +- ...eanApiStarter.Application.UnitTests.csproj | 5 ++ .../CleanApiStarter.Tests.csproj | 5 ++ .../Common/ApiApplicationFactory.cs | 6 +- tests/CleanApiStarter.Tests/GlobalUsings.cs | 2 + 23 files changed, 253 insertions(+), 117 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 CONTRIBUTING.md create mode 100644 src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c390d1d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: nuget + directory: "/" + schedule: + interval: weekly + groups: + nuget-minor-and-patch: + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.template.config/template.json b/.template.config/template.json index e23e59b..970f2ba 100644 --- a/.template.config/template.json +++ b/.template.config/template.json @@ -42,6 +42,7 @@ ".github/workflows/release.yml", ".github/workflows/template.yml", "CleanApiStarter.Template.csproj", + "CONTRIBUTING.md", "scripts/install-template.sh" ] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6681dac --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing to CleanApiStarter + +Thanks for your interest in contributing. This repository is both a working reference application and a packaged `dotnet new` template, so changes are verified in both forms. + +## Prerequisites + +- **.NET SDK** matching [global.json](global.json) (10.0.2xx; `rollForward: latestFeature` applies). +- **Docker** — required to run the API integration tests (Testcontainers) and local PostgreSQL. +- No IDE requirement; the solution file is [CleanApiStarter.slnx](CleanApiStarter.slnx). + +## Getting started + +```bash +git clone https://github.com/cbjpdev/clean-api-starter.git +cd clean-api-starter +dotnet build CleanApiStarter.slnx +dotnet test CleanApiStarter.slnx +``` + +To run the API locally, use the Aspire AppHost (starts PostgreSQL with the schema scripts applied, plus pgAdmin): + +```bash +dotnet run --project src/CleanApiStarter.AppHost +``` + +Alternatively, start PostgreSQL with `docker compose up -d` and run `src/CleanApiStarter.Api` directly. + +See the [README](README.md) for the solution layout and layer dependency rules. The short version: dependencies point inward — `Domain` references nothing, `Application` references only `Domain`, and web/persistence concerns stay in `Api`, `AspNetCore`, and `Infrastructure`. + +## Making changes + +1. Branch from `main` (the convention here is `feature/-short-description`). +2. Keep changes focused; unrelated refactoring belongs in its own PR. +3. Open a pull request against `main`. CI must pass before review. + +### What CI enforces + +The same checks run on every PR ([build.yml](.github/workflows/build.yml), [template.yml](.github/workflows/template.yml), [codeql.yml](.github/workflows/codeql.yml)): + +- **Formatting** — `dotnet format CleanApiStarter.slnx --verify-no-changes`. Run `dotnet format CleanApiStarter.slnx` before pushing; style rules live in [.editorconfig](.editorconfig). +- **Build and tests** — the full suite, including the Testcontainers-based integration tests. +- **Template verification** — the template is packed, installed, and instantiated to confirm generated projects still build. +- **CodeQL** security scanning. + +## Tests + +- Unit tests: `tests/CleanApiStarter.Application.UnitTests` (xUnit v3, AutoFixture, NSubstitute, Shouldly). +- Integration tests: `tests/CleanApiStarter.Api.IntegrationTests` (xUnit v3 against a real PostgreSQL container; Docker must be running). +- Shared test infrastructure (the `ApiApplicationFactory` class fixture, AutoFixture attributes) lives in `tests/CleanApiStarter.Tests`. + +New behavior needs tests. Integration tests share one database per test class, so isolate through unique user ids or resource names rather than assuming a fresh schema. + +For a local coverage report: + +```bash +./scripts/test-coverage.sh +``` + +## Database changes + +The SQL scripts in `database/migrations` are the schema's source of truth; the EF Core entity configurations in `src/CleanApiStarter.Infrastructure/Persistence/Configuration` must be kept in sync with them manually. When changing either, update both. + +Conventions: + +- Timestamp columns are `TIMESTAMP WITH TIME ZONE`; all values are stored in UTC (`DateTime.UtcNow` in code). +- Migration files are named `V__description.sql` and are applied in file-name order by both the AppHost and the integration test factory. +- Migrations must be idempotent (`CREATE TABLE IF NOT EXISTS`, etc.). + +## Template changes + +If your change affects the project structure, file names, or anything under [.template.config/template.json](.template.config/template.json), verify the template output locally: + +```bash +dotnet pack CleanApiStarter.Template.csproj --configuration Release --output artifacts +dotnet new install artifacts/CleanApiStarter.Template.0.0.0.nupkg --force +dotnet new clean-api-starter -n DemoProduct -o /tmp/DemoProduct +dotnet build /tmp/DemoProduct +``` + +Repository-only files (CI release workflows, this guide, install scripts) must be listed in the `exclude` section of `template.json` so they don't ship inside generated projects. + +## Reporting issues + +Open a GitHub issue with reproduction steps, expected vs. actual behavior, and your environment (OS, .NET SDK version). For security vulnerabilities, please do not open a public issue — contact the maintainer directly instead. diff --git a/Directory.Packages.props b/Directory.Packages.props index 4e0ba54..2957aea 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,10 +2,11 @@ true - 13.2.4 - 10.0.0 + 13.4.3 + 10.0.8 10.0.0 - 10.0.0 + 10.0.8 + 10.6.0 @@ -15,43 +16,41 @@ - - + - - - - - + + + + + - + - - - - + + - - - + + + - - - - + + + + - - + + + - + \ No newline at end of file diff --git a/README.md b/README.md index d1678e0..a8c68c4 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The project is intentionally both a template and a working reference application - FluentValidation endpoint validation returning `422 Unprocessable Entity`. - Result wrappers for collection endpoints: `PaginatedResult` and `ArrayResult`. - xUnit v3 unit tests with AutoFixture, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly. -- MSTest API integration tests with Testcontainers for PostgreSQL. +- xUnit v3 API integration tests with Testcontainers for PostgreSQL. - Local coverage script that generates an HTML report with ReportGenerator. - GitHub Actions CI with build, test, template verification, and CodeQL security scanning. @@ -462,7 +462,7 @@ dotnet test tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Applicat API integration tests use: -- MSTest +- xUnit v3 - Microsoft.AspNetCore.Mvc.Testing - Testcontainers for PostgreSQL - Shouldly diff --git a/database/migrations/V001__create_projects_and_tasks_tables.sql b/database/migrations/V001__create_projects_and_tasks_tables.sql index e2b7799..5c7b4f9 100644 --- a/database/migrations/V001__create_projects_and_tasks_tables.sql +++ b/database/migrations/V001__create_projects_and_tasks_tables.sql @@ -3,13 +3,13 @@ CREATE TABLE IF NOT EXISTS projects ( name VARCHAR(100) NOT NULL, description TEXT NOT NULL, owner_user_id VARCHAR(450) NOT NULL, - created_at TIMESTAMP NOT NULL + created_at TIMESTAMP WITH TIME ZONE NOT NULL ); CREATE TABLE IF NOT EXISTS project_members ( project_id UUID NOT NULL, user_id VARCHAR(450) NOT NULL, - created_at TIMESTAMP NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT pk_project_members PRIMARY KEY (project_id, user_id), CONSTRAINT fk_project_members_projects_project_id FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE @@ -21,9 +21,9 @@ CREATE TABLE IF NOT EXISTS project_tasks ( title VARCHAR(150) NOT NULL, description TEXT NOT NULL, status VARCHAR(20) NOT NULL, - due_date TIMESTAMP NULL, - created_at TIMESTAMP NOT NULL, - completed_at TIMESTAMP NULL, + due_date TIMESTAMP WITH TIME ZONE NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL, + completed_at TIMESTAMP WITH TIME ZONE NULL, CONSTRAINT fk_project_tasks_projects_project_id FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE ); diff --git a/scripts/test-coverage.sh b/scripts/test-coverage.sh index 2d23317..d7f0b9d 100755 --- a/scripts/test-coverage.sh +++ b/scripts/test-coverage.sh @@ -44,4 +44,17 @@ dotnet reportgenerator \ coverage_index="$coverage_report_dir/index.html" echo "Coverage report: $coverage_index" -open -a "Google Chrome" "$coverage_index" + +case "$(uname -s)" in + Darwin) + open "$coverage_index" + ;; + Linux) + if command -v xdg-open > /dev/null && [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then + xdg-open "$coverage_index" + fi + ;; + MINGW*|MSYS*|CYGWIN*) + cmd.exe //c start "" "$(cygpath -w "$coverage_index")" + ;; +esac diff --git a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj index c710c1c..49fc06b 100644 --- a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +++ b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj @@ -6,13 +6,6 @@ clean-api-starter - - - - - - - diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs index 9f9d081..58be6da 100644 --- a/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -4,8 +4,6 @@ global using System.Security.Claims; global using System.Text.Encodings.Web; -global using Asp.Versioning.ApiExplorer; - global using CleanApiStarter.Api.Endpoints; global using CleanApiStarter.Api.Services; global using CleanApiStarter.Application; @@ -21,5 +19,3 @@ global using Microsoft.AspNetCore.Http; global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Routing; - -global using Scalar.AspNetCore; diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs index 4b435c6..4637d70 100644 --- a/src/CleanApiStarter.Api/Program.cs +++ b/src/CleanApiStarter.Api/Program.cs @@ -9,28 +9,8 @@ WebApplication app = builder.Build(); -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi() - .WithDocumentPerVersion(); - - app.MapScalarApiReference(options => - { - IReadOnlyList descriptions = app.DescribeApiVersions(); - - for (int index = 0; index < descriptions.Count; index++) - { - ApiVersionDescription description = descriptions[index]; - bool isDefault = index == descriptions.Count - 1; - - options.AddDocument(description.GroupName, description.GroupName, isDefault: isDefault); - } - }); -} - app.UseAspNetCoreDefaults(); -app.UseHttpsRedirection(); +app.MapOpenApiDocumentation(); app.MapGoogleLoginPage(); app.MapEndpoints(Assembly.GetExecutingAssembly()); app.MapDefaultEndpoints(); diff --git a/src/CleanApiStarter.AppHost/Program.cs b/src/CleanApiStarter.AppHost/Program.cs index a5848d6..7c5b157 100644 --- a/src/CleanApiStarter.AppHost/Program.cs +++ b/src/CleanApiStarter.AppHost/Program.cs @@ -4,7 +4,7 @@ string databaseMigrationsPath = Path.Combine(databasePath, "migrations"); IResourceBuilder postgresServer = builder.AddPostgres("postgres-server") - .WithImageTag("latest") + .WithImageTag("18") .WithVolume("clean-api-starter-postgres-data", "/var/lib/postgresql") .WithInitFiles(databaseMigrationsPath) .WithPgAdmin(); diff --git a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj index b0e57f8..a27ae2d 100644 --- a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj +++ b/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj @@ -16,6 +16,8 @@ + + diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/src/CleanApiStarter.AspNetCore/Extensions.cs index 3f223ad..ca8c5b6 100644 --- a/src/CleanApiStarter.AspNetCore/Extensions.cs +++ b/src/CleanApiStarter.AspNetCore/Extensions.cs @@ -26,6 +26,12 @@ public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBui public static WebApplication UseAspNetCoreDefaults(this WebApplication app) { + if (!app.Environment.IsDevelopment()) + { + app.UseHsts(); + } + + app.UseHttpsRedirection(); app.UseMiddleware(); app.UseExceptionHandler(); app.UseResponseCompression(); @@ -52,16 +58,17 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) }); }) .WithName("GetVersion") - .WithTags("Version"); + .WithTags("Version") + .AllowAnonymous(); - if (app.Environment.IsDevelopment()) + app.MapHealthChecks("/health") + .AllowAnonymous(); + + app.MapHealthChecks("/alive", new HealthCheckOptions { - app.MapHealthChecks("/health"); - app.MapHealthChecks("/alive", new HealthCheckOptions - { - Predicate = registration => registration.Tags.Contains("live") - }); - } + Predicate = registration => registration.Tags.Contains("live") + }) + .AllowAnonymous(); return app; } diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs index 0f611ae..87cc140 100644 --- a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs +++ b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs @@ -31,6 +31,8 @@ global using Npgsql; +global using Scalar.AspNetCore; + global using OpenTelemetry.Logs; global using OpenTelemetry.Metrics; global using OpenTelemetry.Trace; diff --git a/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs b/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs new file mode 100644 index 0000000..fba74c4 --- /dev/null +++ b/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs @@ -0,0 +1,32 @@ +namespace CleanApiStarter.AspNetCore; + +public static class OpenApiDocumentationExtensions +{ + public static WebApplication MapOpenApiDocumentation(this WebApplication app) + { + if (!app.Environment.IsDevelopment()) + { + return app; + } + + app.MapOpenApi() + .WithDocumentPerVersion() + .AllowAnonymous(); + + app.MapScalarApiReference(options => + { + IReadOnlyList descriptions = app.DescribeApiVersions(); + + for (int index = 0; index < descriptions.Count; index++) + { + ApiVersionDescription description = descriptions[index]; + bool isDefault = index == descriptions.Count - 1; + + options.AddDocument(description.GroupName, description.GroupName, isDefault: isDefault); + } + }) + .AllowAnonymous(); + + return app; + } +} diff --git a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs index fe50223..ee1e506 100644 --- a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs +++ b/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs @@ -1,12 +1,27 @@ namespace CleanApiStarter.AspNetCore; -public sealed class ProblemDetailsExceptionHandler(ILogger logger) : IExceptionHandler +public sealed class ProblemDetailsExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger) : IExceptionHandler { public async ValueTask TryHandleAsync( HttpContext httpContext, Exception exception, CancellationToken cancellationToken) { + if (exception is OperationCanceledException && httpContext.RequestAborted.IsCancellationRequested) + { + logger.LogDebug( + "Request {RequestMethod} {RequestPath} was canceled by the client", + httpContext.Request.Method, + httpContext.Request.Path); + + // 499 Client Closed Request (nginx convention); no body since the client is gone. + httpContext.Response.StatusCode = 499; + + return true; + } + (int statusCode, ProblemDetails problemDetails) = exception switch { BadHttpRequestException badRequestException => ( @@ -18,15 +33,6 @@ public async ValueTask TryHandleAsync( Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", Detail = badRequestException.Message }), - ArgumentException argumentException => ( - StatusCodes.Status400BadRequest, - new ProblemDetails - { - Status = StatusCodes.Status400BadRequest, - Title = "Bad Request", - Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", - Detail = argumentException.Message - }), UnauthorizedAccessException => ( StatusCodes.Status401Unauthorized, new ProblemDetails @@ -65,8 +71,12 @@ public async ValueTask TryHandleAsync( } httpContext.Response.StatusCode = statusCode; - await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken); - return true; + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + ProblemDetails = problemDetails, + Exception = exception + }); } } diff --git a/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj b/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj index fd36048..275bbf0 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj +++ b/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj @@ -8,9 +8,9 @@ - - + + @@ -22,4 +22,8 @@ + + + + diff --git a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs b/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs index eebc218..bc17707 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs +++ b/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs @@ -1,33 +1,19 @@ namespace CleanApiStarter.Api.IntegrationTests.Features.Projects; -[TestClass] -public sealed class ProjectsTests +public sealed class ProjectsTests : IClassFixture> { - private ApiApplicationFactory _applicationFactory = null!; + private readonly HttpClient _client; - private HttpClient _client = null!; - - [TestInitialize] - public async Task TestInitialize() + public ProjectsTests(ApiApplicationFactory applicationFactory) { - _applicationFactory = new ApiApplicationFactory(); - await _applicationFactory.InitializeAsync(); - - _client = _applicationFactory.CreateClient(); + _client = applicationFactory.CreateClient(); _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( "Bearer", - _applicationFactory.CreateAccessToken("integration-test-user")); - } - - [TestCleanup] - public async Task TestCleanup() - { - _client.Dispose(); - await _applicationFactory.DisposeAsync(); + applicationFactory.CreateAccessToken("integration-test-user")); } - [TestMethod] + [Fact] public async Task Projects_PostAuthenticatedRequest_CreatesProject() { // Arrange @@ -39,17 +25,16 @@ public async Task Projects_PostAuthenticatedRequest_CreatesProject() // Act HttpResponseMessage response = await _client.PostAsJsonAsync("/api/projects", request, - cancellationToken: TestContext.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); // Assert response.StatusCode.ShouldBe(HttpStatusCode.Created); - Guid projectId = await response.Content.ReadFromJsonAsync(); + Guid projectId = await response.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken); projectId.ShouldNotBe(Guid.Empty); response.Headers.Location.ShouldNotBeNull(); response.Headers.Location!.ToString().ShouldContain($"/api/projects/{projectId}"); } - - public TestContext TestContext { get; set; } } diff --git a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs b/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs index 3fa1cfb..d1837d5 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs +++ b/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs @@ -4,6 +4,6 @@ global using CleanApiStarter.Tests.Common; -global using Microsoft.VisualStudio.TestTools.UnitTesting; - global using Shouldly; + +global using Xunit; diff --git a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj b/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj index 9f3d723..84de8d1 100644 --- a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj +++ b/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj @@ -1,4 +1,5 @@ + CleanApiStarter.Application.UnitTests CleanApiStarter.Application.UnitTests @@ -20,4 +21,8 @@ + + + + diff --git a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj b/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj index 5faf723..3167dbc 100644 --- a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj +++ b/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj @@ -12,6 +12,11 @@ + + + + + diff --git a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs b/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs index feab947..01f01a0 100644 --- a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs +++ b/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs @@ -1,19 +1,19 @@ namespace CleanApiStarter.Tests.Common; -public sealed class ApiApplicationFactory : WebApplicationFactory +public sealed class ApiApplicationFactory : WebApplicationFactory, IAsyncLifetime where TProgram : class { private const string TestIssuer = "CleanApiStarter.Tests"; private const string TestAudience = "CleanApiStarter.Api.Tests"; private const string TestSigningKey = "integration-tests-signing-key-change-me"; - private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:latest") + private readonly PostgreSqlContainer postgres = new PostgreSqlBuilder("postgres:18") .WithDatabase("postgres") .WithUsername("postgres") .WithPassword("postgres") .Build(); - public async Task InitializeAsync() + public async ValueTask InitializeAsync() { await postgres.StartAsync(); await RunDatabaseScriptsAsync(); diff --git a/tests/CleanApiStarter.Tests/GlobalUsings.cs b/tests/CleanApiStarter.Tests/GlobalUsings.cs index 5aa93fe..5d1b365 100644 --- a/tests/CleanApiStarter.Tests/GlobalUsings.cs +++ b/tests/CleanApiStarter.Tests/GlobalUsings.cs @@ -14,3 +14,5 @@ global using Npgsql; global using Testcontainers.PostgreSql; + +global using Xunit; From 29d303a9134ae55b05e14f860a5f34fc88ede928 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:13:44 +0000 Subject: [PATCH 14/33] Bump actions/setup-dotnet from 4 to 5 Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 5. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f86f00a..5c220d8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v4 - name: Setup .NET - uses: actions/setup-dotnet@v4 + uses: actions/setup-dotnet@v5 with: global-json-file: global.json From 10911aa41623692db0be849d961b783de76aac9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:13:48 +0000 Subject: [PATCH 15/33] Bump actions/checkout from 4 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f86f00a..2c29164 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup .NET uses: actions/setup-dotnet@v4 From 521dcbc49afea3450a318cd68151a46677ccc1fe Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:16:19 +0800 Subject: [PATCH 16/33] remove tracked .DS_Store files --- .DS_Store | Bin 6148 -> 0 bytes src/.DS_Store | Bin 6148 -> 0 bytes 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store delete mode 100644 src/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index bc3010721f20b539a5abb96da48af9d7e565fe94..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKO^ee&7=EYi+SFC-ii#H@;7v+xMOnd1+f5G(u7`~u2bJ11WjB~+LettqS;$cl zWdDgbe}ZREdh(BW(Puu&CT-Wl9z>RTVdj~c_jxDpJR~z2B4VAX-z2IMk%c0#bP?4W z6Mj)vte{7(0GXU4qBg}8P@R$$wiz%A7zO?}1;n>|iFPQYA@wQy{e~3nhJ4_K+yh5! z5T8-%zonr?RHnzYi`XtbA(y&&HoLXNuFyW1ZE|Q2Sf>&BOA#NJB8u2W#xz)BZGrVO zlZ9q^vn1BJe(~ZkNG6TOcUCBtHZGK{vbAZwb`SZ?o%AQuq|+b26uqaMhu)&y_nrsC zS+9C+hewls5DmvNAn=Evym}EtKA&~?H1ZP}>nev;u`0dljrqJ$v$yXZ)$Dom{)6qB z{Scj_igo?=-ADVqx8W$_?~yl*VW*UKS>pmeprO|i9yHwnAH@sTiD$$eN3YU4{&n@! zUi)i1>-GQQjSo+o^_@G8LLMDpCXw9+P4G4(sFS?*g=Zk#$!MQpuE{y$&E~!JfOD~l6nt&Q>qMVR2F3S|f?eH_b% fIErtgNQ2K74PZ-Sst_YE^CKW-FqKi@pDOSNPt&_I diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 84e94681384b6e237748307f35fa40cc9bbe9497..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}N6?5T4j-Q;N`oipPN0f~_bOysX>$0JK=RFGW|Ex`Ha`F${As@hPzL~sjZl!XLTGrUYr_Q-3NuFv=P-mg%ErSL z6aB>@`gRE&NMHaVeEa?=(GmJSqG1w`C(Y)k*eI1Zw<@wCYw|vL(pfMGC)2SXj&8}i z(mII>J&dm7LDsA9baXli<8&}G32}dbA$K=%+Si${r)hs|as%s-Rax!T_hz$ZL$#0Q z4K-^WpR^n56peXR?j9VTUG|=nmsG!5Dh0A`WZPi@@2D(Q^%{)QM5mAFEAonXMrME+ zUpUs%q^k^ zMc7nCn=0HBL)dg2S1!(Tm|L{zAk4}*k6XETya=;8jw=%m!n4RdGr$ZiGElN+ozDLY z{AE@?@)uLsVg{Ijf5w0)cZ05vMY*$esXRJs6}AU#BovpEf`a)ZDNA``=`W5LI Z=Q+$R(kwJ?(h>P0U Date: Thu, 11 Jun 2026 02:18:07 +0000 Subject: [PATCH 17/33] Bump the nuget-minor-and-patch group with 8 updates Bumps dotnet-reportgenerator-globaltool from 5.5.5 to 5.5.10 Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.8 to 10.0.9 Bumps Microsoft.Extensions.Configuration.Abstractions from 10.0.8 to 10.0.9 Bumps Microsoft.Extensions.Http.Resilience from 10.6.0 to 10.7.0 Bumps OpenTelemetry from 1.15.3 to 1.16.0 Bumps OpenTelemetry.Exporter.OpenTelemetryProtocol from 1.15.3 to 1.16.0 Bumps OpenTelemetry.Extensions.Hosting from 1.15.3 to 1.16.0 Bumps Scalar.AspNetCore from 2.16.1 to 2.16.2 --- updated-dependencies: - dependency-name: dotnet-reportgenerator-globaltool dependency-version: 5.5.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer dependency-version: 10.0.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: Microsoft.Extensions.Configuration.Abstractions dependency-version: 10.0.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: Microsoft.Extensions.Http.Resilience dependency-version: 10.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry dependency-version: 1.16.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Exporter.OpenTelemetryProtocol dependency-version: 1.16.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Extensions.Hosting dependency-version: 1.16.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Scalar.AspNetCore dependency-version: 2.16.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch ... Signed-off-by: dependabot[bot] --- .config/dotnet-tools.json | 4 ++-- Directory.Packages.props | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index a4ed703..9288b0e 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,10 +3,10 @@ "isRoot": true, "tools": { "dotnet-reportgenerator-globaltool": { - "version": "5.5.5", + "version": "5.5.10", "commands": [ "reportgenerator" ] } } -} +} \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 2957aea..40f1782 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,10 +3,10 @@ true 13.4.3 - 10.0.8 + 10.0.9 10.0.0 - 10.0.8 - 10.6.0 + 10.0.9 + 10.7.0 @@ -39,13 +39,13 @@ - - - + + + - + From a18b799e2c078384148874db8414feb0731e4ec4 Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:18:21 +0800 Subject: [PATCH 18/33] add Scalar.AspNetCore to global usings --- src/CleanApiStarter.AspNetCore/GlobalUsings.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs index 87cc140..1a87110 100644 --- a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs +++ b/src/CleanApiStarter.AspNetCore/GlobalUsings.cs @@ -31,8 +31,8 @@ global using Npgsql; -global using Scalar.AspNetCore; - global using OpenTelemetry.Logs; global using OpenTelemetry.Metrics; global using OpenTelemetry.Trace; + +global using Scalar.AspNetCore; From c88470fd038a8887eaac76db4723b95f4bc78e3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:07:29 +0000 Subject: [PATCH 19/33] Bump the nuget-minor-and-patch group with 1 update Bumps Scalar.AspNetCore from 2.16.2 to 2.16.3 --- updated-dependencies: - dependency-name: Scalar.AspNetCore dependency-version: 2.16.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 40f1782..29b776d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,7 +45,7 @@ - + From d90ac9484a9bc4a58b24032bc724d19f860253ff Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:28:56 +0800 Subject: [PATCH 20/33] update project references and package versions; add MessagePack dependency --- Directory.Packages.props | 6 +++- .../CleanApiStarter.AppHost.csproj | 30 +++++++++++-------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 29b776d..5b5aa04 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,7 @@ true - 13.4.3 + 13.4.4 10.0.9 10.0.0 10.0.9 @@ -53,4 +53,8 @@ + + + + \ No newline at end of file diff --git a/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj b/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj index d40f932..8fd8e8e 100644 --- a/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj +++ b/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj @@ -1,19 +1,23 @@ - - Exe - CleanApiStarter.AppHost - CleanApiStarter.AppHost - clean-api-starter-apphost - + + Exe + CleanApiStarter.AppHost + CleanApiStarter.AppHost + clean-api-starter-apphost + - - - - + + + + - - - + + + + + + + From af3128c0394f66a20902c9dcf88d6013055f2d5d Mon Sep 17 00:00:00 2001 From: Chathuranga Pathirana <42979311+cstacklab@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:28:08 +0800 Subject: [PATCH 21/33] Add single project structure --- .claude/skills/add-endpoint/SKILL.md | 85 +++ .claude/skills/add-migration/SKILL.md | 39 + .editorconfig | 714 +++++++++--------- .gitignore | 3 + .template.config/template.json | 3 + AGENTS.md | 181 ----- CONTRIBUTING.md | 94 ++- CleanApiStarter.Template.csproj | 2 +- CleanApiStarter.slnx | 43 +- Directory.Build.props | 18 +- Directory.Packages.props | 1 + README.md | 627 ++------------- adr/ADR-001.md | 70 ++ adr/ADR-002.md | 82 ++ adr/ADR-003.md | 90 +++ adr/README.md | 20 + .../clean-architecture-and-vertical-slices.md | 142 ++++ scripts/install-template.sh | 6 +- .../CleanApiStarter.Api.csproj | 19 +- .../Common/Interfaces/IUser.cs | 2 +- .../Common/Interfaces/UserExtensions.cs | 13 + .../Common/Models/ArrayResult.cs | 2 +- .../Common/Models/PaginatedQuery.cs | 2 +- .../Common/Models/PaginatedQueryValidator.cs | 2 +- .../Common/Models/PaginatedResult.cs | 2 +- .../Configuration}/AppSettings.cs | 2 +- .../AuthenticationConfiguration.cs | 27 + .../Configuration}/AuthenticationSettings.cs | 2 +- .../ConnectionStringSettings.cs | 2 +- .../GoogleAuthenticationSettings.cs | 2 +- .../JwtAuthenticationSettings.cs | 2 +- .../OptionsRegistrationExtensions.cs | 2 +- .../Domain}/Entities/Project.cs | 2 +- .../Domain}/Entities/ProjectMember.cs | 2 +- .../Domain}/Entities/ProjectTask.cs | 2 +- .../Domain}/Entities/ProjectTaskStatus.cs | 2 +- src/CleanApiStarter.Api/Endpoints/V1/Auth.cs | 39 - .../Endpoints/V1/Projects.cs | 176 ----- .../Endpoints/V2/Projects.cs | 35 - src/CleanApiStarter.Api/Features/Auth/Auth.cs | 14 + .../Features/Auth/AuthTokenDto.cs | 2 +- .../Features/Auth/CurrentUserDto.cs | 2 +- .../Features/Auth/GetCurrentUser.cs | 21 + .../Auth}/GoogleLoginPage.cs | 2 +- .../Features/Auth/IAuthService.cs | 8 + .../Features/Auth/SignInWithGoogle.cs | 32 + .../Features/Projects/CreateProject.cs | 57 ++ .../Features/Projects/DeleteProject.cs | 33 + .../Features/Projects/GetProject.cs | 23 + .../Features/Projects/GetProjects.cs | 24 + .../Features/Projects/IProjectRepository.cs | 2 +- .../Features/Projects/ProjectDto.cs | 26 + .../Features/Projects/Projects.cs | 25 + .../Features/Projects/Tasks/CompleteTask.cs | 41 + .../Features/Projects/Tasks/CreateTask.cs | 60 ++ .../Features/Projects/Tasks/DeleteTask.cs | 29 + .../Features/Projects/Tasks/GetTask.cs | 28 + .../Features/Projects/Tasks/GetTasks.cs | 35 + .../Features/Projects/Tasks/ProjectTaskDto.cs | 35 + .../Features/Projects/Tasks/UpdateTask.cs | 67 ++ .../Features/Projects/V2/GetProjects.cs | 35 + .../Features/Projects/V2/Projects.cs | 15 + src/CleanApiStarter.Api/GlobalUsings.cs | 40 +- .../Identity/ApplicationUser.cs | 3 + .../Identity/GoogleAuthService.cs | 6 +- .../InfrastructureExtensions.cs} | 4 +- .../Persistence/ApplicationDbContext.cs | 2 +- .../Configuration/ProjectConfiguration.cs | 2 +- .../ProjectMemberConfiguration.cs | 2 +- .../Configuration/ProjectTaskConfiguration.cs | 2 +- .../Repositories/ProjectRepository.cs | 2 +- src/CleanApiStarter.Api/Program.cs | 3 +- .../Properties/launchSettings.json | 30 +- src/CleanApiStarter.Api/appsettings.json | 18 +- src/CleanApiStarter.Api/config.nsdepcop | 18 + .../CleanApiStarter.Application.csproj | 18 - .../DependencyInjection.cs | 12 - .../Features/Auth/GoogleSignInDto.cs | 6 - .../Features/Auth/GoogleSignInDtoValidator.cs | 10 - .../Features/Auth/IAuthService.cs | 8 - .../Projects/CreateProjectDtoValidator.cs | 14 - .../Projects/CreateProjectTaskDtoValidator.cs | 19 - .../Features/Projects/IProjectService.cs | 32 - .../Features/Projects/ProjectDto.cs | 21 - .../Projects/ProjectOperationResults.cs | 15 - .../Features/Projects/ProjectService.cs | 248 ------ .../Features/Projects/ProjectTaskDto.cs | 40 - .../Projects/UpdateProjectTaskDtoValidator.cs | 24 - .../GlobalUsings.cs | 11 - .../AspNetCoreDefaultServices.cs | 24 +- .../AspNetCoreDefaultsExtensions.cs} | 6 +- ...CleanApiStarter.AspNetCoreDefaults.csproj} | 8 +- .../GlobalUsings.cs | 2 - .../IEndpointGroup.cs | 2 +- .../OpenApiDocumentationExtensions.cs | 2 +- .../OpenTelemetryDefaults.cs | 4 +- .../ProblemDetailsExceptionHandler.cs | 2 +- .../RequestIdMiddleware.cs | 4 +- .../UserIdHttpLoggingInterceptor.cs | 2 +- .../ValidationFilter.cs | 2 +- .../WebApplicationExtensions.cs | 2 +- .../CleanApiStarter.Configuration.csproj | 15 - .../GlobalUsings.cs | 5 - .../CleanApiStarter.Domain.csproj | 6 - .../CleanApiStarter.Infrastructure.csproj | 20 - .../GlobalUsings.cs | 21 - .../Identity/ApplicationUser.cs | 3 - .../GlobalUsings.cs | 14 - .../CleanApiStarter.IntegrationTests.csproj} | 6 +- .../Features/Projects/ProjectsTests.cs | 2 +- .../GlobalUsings.cs | 2 +- .../appsettings.Testing.json | 0 .../CleanApiStarter.TestUtilities.csproj} | 4 +- .../Common/ApiApplicationFactory.cs | 2 +- .../Common/AutoNSubstituteDataAttribute.cs | 2 +- .../GlobalUsings.cs | 0 .../CleanApiStarter.UnitTests.csproj} | 12 +- .../Projects/Tasks/CompleteTaskTests.cs} | 13 +- .../CleanApiStarter.UnitTests/GlobalUsings.cs | 16 + 119 files changed, 1898 insertions(+), 2090 deletions(-) create mode 100644 .claude/skills/add-endpoint/SKILL.md create mode 100644 .claude/skills/add-migration/SKILL.md delete mode 100644 AGENTS.md create mode 100644 adr/ADR-001.md create mode 100644 adr/ADR-002.md create mode 100644 adr/ADR-003.md create mode 100644 adr/README.md create mode 100644 docs/architecture/clean-architecture-and-vertical-slices.md rename src/{CleanApiStarter.Application => CleanApiStarter.Api}/Common/Interfaces/IUser.cs (58%) create mode 100644 src/CleanApiStarter.Api/Common/Interfaces/UserExtensions.cs rename src/{CleanApiStarter.Application => CleanApiStarter.Api}/Common/Models/ArrayResult.cs (72%) rename src/{CleanApiStarter.Application => CleanApiStarter.Api}/Common/Models/PaginatedQuery.cs (69%) rename src/{CleanApiStarter.Application => CleanApiStarter.Api}/Common/Models/PaginatedQueryValidator.cs (84%) rename src/{CleanApiStarter.Application => CleanApiStarter.Api}/Common/Models/PaginatedResult.cs (92%) rename src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/AppSettings.cs (85%) create mode 100644 src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs rename src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/AuthenticationSettings.cs (85%) rename src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/ConnectionStringSettings.cs (73%) rename src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/GoogleAuthenticationSettings.cs (73%) rename src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/JwtAuthenticationSettings.cs (88%) rename src/{CleanApiStarter.Configuration => CleanApiStarter.Api/Configuration}/OptionsRegistrationExtensions.cs (91%) rename src/{CleanApiStarter.Domain => CleanApiStarter.Api/Domain}/Entities/Project.cs (89%) rename src/{CleanApiStarter.Domain => CleanApiStarter.Api/Domain}/Entities/ProjectMember.cs (83%) rename src/{CleanApiStarter.Domain => CleanApiStarter.Api/Domain}/Entities/ProjectTask.cs (91%) rename src/{CleanApiStarter.Domain => CleanApiStarter.Api/Domain}/Entities/ProjectTaskStatus.cs (63%) delete mode 100644 src/CleanApiStarter.Api/Endpoints/V1/Auth.cs delete mode 100644 src/CleanApiStarter.Api/Endpoints/V1/Projects.cs delete mode 100644 src/CleanApiStarter.Api/Endpoints/V2/Projects.cs create mode 100644 src/CleanApiStarter.Api/Features/Auth/Auth.cs rename src/{CleanApiStarter.Application => CleanApiStarter.Api}/Features/Auth/AuthTokenDto.cs (74%) rename src/{CleanApiStarter.Application => CleanApiStarter.Api}/Features/Auth/CurrentUserDto.cs (82%) create mode 100644 src/CleanApiStarter.Api/Features/Auth/GetCurrentUser.cs rename src/CleanApiStarter.Api/{Endpoints => Features/Auth}/GoogleLoginPage.cs (99%) create mode 100644 src/CleanApiStarter.Api/Features/Auth/IAuthService.cs create mode 100644 src/CleanApiStarter.Api/Features/Auth/SignInWithGoogle.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/CreateProject.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/DeleteProject.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/GetProject.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/GetProjects.cs rename src/{CleanApiStarter.Application => CleanApiStarter.Api}/Features/Projects/IProjectRepository.cs (96%) create mode 100644 src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Projects.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/CompleteTask.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/CreateTask.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/DeleteTask.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/GetTask.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/GetTasks.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateTask.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/V2/GetProjects.cs create mode 100644 src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs create mode 100644 src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs rename src/{CleanApiStarter.Infrastructure => CleanApiStarter.Api/Infrastructure}/Identity/GoogleAuthService.cs (96%) rename src/{CleanApiStarter.Infrastructure/DependencyInjection.cs => CleanApiStarter.Api/Infrastructure/InfrastructureExtensions.cs} (88%) rename src/{CleanApiStarter.Infrastructure => CleanApiStarter.Api/Infrastructure}/Persistence/ApplicationDbContext.cs (90%) rename src/{CleanApiStarter.Infrastructure => CleanApiStarter.Api/Infrastructure}/Persistence/Configuration/ProjectConfiguration.cs (92%) rename src/{CleanApiStarter.Infrastructure => CleanApiStarter.Api/Infrastructure}/Persistence/Configuration/ProjectMemberConfiguration.cs (92%) rename src/{CleanApiStarter.Infrastructure => CleanApiStarter.Api/Infrastructure}/Persistence/Configuration/ProjectTaskConfiguration.cs (94%) rename src/{CleanApiStarter.Infrastructure => CleanApiStarter.Api/Infrastructure}/Repositories/ProjectRepository.cs (98%) create mode 100644 src/CleanApiStarter.Api/config.nsdepcop delete mode 100644 src/CleanApiStarter.Application/CleanApiStarter.Application.csproj delete mode 100644 src/CleanApiStarter.Application/DependencyInjection.cs delete mode 100644 src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs delete mode 100644 src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs delete mode 100644 src/CleanApiStarter.Application/Features/Auth/IAuthService.cs delete mode 100644 src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs delete mode 100644 src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs delete mode 100644 src/CleanApiStarter.Application/Features/Projects/IProjectService.cs delete mode 100644 src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs delete mode 100644 src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs delete mode 100644 src/CleanApiStarter.Application/Features/Projects/ProjectService.cs delete mode 100644 src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs delete mode 100644 src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs delete mode 100644 src/CleanApiStarter.Application/GlobalUsings.cs rename src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/AspNetCoreDefaultServices.cs (77%) rename src/{CleanApiStarter.AspNetCore/Extensions.cs => CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultsExtensions.cs} (93%) rename src/{CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj => CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj} (82%) rename src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/GlobalUsings.cs (96%) rename src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/IEndpointGroup.cs (80%) rename src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/OpenApiDocumentationExtensions.cs (95%) rename src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/OpenTelemetryDefaults.cs (93%) rename src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/ProblemDetailsExceptionHandler.cs (98%) rename src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/RequestIdMiddleware.cs (83%) rename src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/UserIdHttpLoggingInterceptor.cs (95%) rename src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/ValidationFilter.cs (97%) rename src/{CleanApiStarter.AspNetCore => CleanApiStarter.AspNetCoreDefaults}/WebApplicationExtensions.cs (96%) delete mode 100644 src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj delete mode 100644 src/CleanApiStarter.Configuration/GlobalUsings.cs delete mode 100644 src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj delete mode 100644 src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj delete mode 100644 src/CleanApiStarter.Infrastructure/GlobalUsings.cs delete mode 100644 src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs delete mode 100644 tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs rename tests/{CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj => CleanApiStarter.IntegrationTests/CleanApiStarter.IntegrationTests.csproj} (77%) rename tests/{CleanApiStarter.Api.IntegrationTests => CleanApiStarter.IntegrationTests}/Features/Projects/ProjectsTests.cs (95%) rename tests/{CleanApiStarter.Api.IntegrationTests => CleanApiStarter.IntegrationTests}/GlobalUsings.cs (73%) rename tests/{CleanApiStarter.Api.IntegrationTests => CleanApiStarter.IntegrationTests}/appsettings.Testing.json (100%) rename tests/{CleanApiStarter.Tests/CleanApiStarter.Tests.csproj => CleanApiStarter.TestUtilities/CleanApiStarter.TestUtilities.csproj} (85%) rename tests/{CleanApiStarter.Tests => CleanApiStarter.TestUtilities}/Common/ApiApplicationFactory.cs (98%) rename tests/{CleanApiStarter.Tests => CleanApiStarter.TestUtilities}/Common/AutoNSubstituteDataAttribute.cs (84%) rename tests/{CleanApiStarter.Tests => CleanApiStarter.TestUtilities}/GlobalUsings.cs (100%) rename tests/{CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj => CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj} (65%) rename tests/{CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs => CleanApiStarter.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs} (67%) create mode 100644 tests/CleanApiStarter.UnitTests/GlobalUsings.cs diff --git a/.claude/skills/add-endpoint/SKILL.md b/.claude/skills/add-endpoint/SKILL.md new file mode 100644 index 0000000..4c244bf --- /dev/null +++ b/.claude/skills/add-endpoint/SKILL.md @@ -0,0 +1,85 @@ +--- +name: add-endpoint +description: Add or change an API endpoint in this codebase as a vertical slice — one file per operation co-locating its request, validator, endpoint mapping, and handler. Use when adding an endpoint, operation, or whole feature to the CleanApiStarter API. +paths: src/CleanApiStarter.Api/Features/** +--- + +# Add an API endpoint (vertical slice) + +This API is a single `CleanApiStarter.Api` project organised as **vertical slices**: +one file per operation. Boundaries are enforced at build time by NsDepCop +(`src/CleanApiStarter.Api/config.nsdepcop`) — a handler must not reference an +`Infrastructure` type or the build fails. + +## Add an operation to an existing feature + +1. Create one file per operation in the feature folder, e.g. + `src/CleanApiStarter.Api/Features/Projects/ArchiveProject.cs`: + + ```csharp + namespace CleanApiStarter.Api.Features.Projects; + + public static class ArchiveProject + { + public sealed record Request(string Reason); // omit if no body + + public sealed class Validator : AbstractValidator + { + public Validator() => RuleFor(request => request.Reason).NotEmpty(); + } + + public static void Map(RouteGroupBuilder group) => + group.MapPost("/{id:guid}/archive", Handle).WithName("ArchiveProjectV1"); + + private static async Task Handle( + Guid id, + Request request, + IProjectRepository projectRepository, // an interface — never an Infrastructure type + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + // orchestrate via the repository, then return a typed result + return TypedResults.NoContent(); + } + } + ``` + +2. Register the slice in the feature's endpoint group `Map` + (`Features/Projects/Projects.cs`): add `ArchiveProject.Map(groupBuilder);`. + +3. If the handler needs new data access, add a method to `IProjectRepository` + (`Features/Projects/IProjectRepository.cs`) and implement it in + `Infrastructure/Repositories/ProjectRepository.cs`. + +## Add a new feature (capability) + +1. Create `Features//`. +2. Add a thin endpoint group implementing `IEndpointGroup` — it sets `RoutePrefix`, + `MajorVersion`, and a `Map` that calls each slice's `Map`. It is discovered by + reflection; no manual registration needed. +3. Add slices as above. Put a nested resource in a sub-folder (e.g. + `Projects/Tasks/`) and a new API version in a `V2/` sub-folder. + +## Conventions + +- **Handlers depend only on interfaces** (`IProjectRepository`, `IAuthService`), + never on `Infrastructure` types. NsDepCop (`Features ✗→ Infrastructure`) breaks + the build otherwise. +- Validators are auto-registered (`AddValidatorsFromAssembly`) and run by the global + `ValidationFilter`, returning `422` on failure. Just add a nested + `Validator : AbstractValidator`. +- Return `TypedResults.*` (`Ok` / `Created` / `NoContent` / `NotFound` / + `Conflict` / `Forbid`) directly — there is no result-enum indirection. +- Map entities to DTOs with a static `From(...)` factory on the DTO + (e.g. `ProjectDto.From(project)`); for pages use `result.Map(ProjectDto.From)`. +- Group-level auth: the `Projects` group calls `RequireAuthorization()`; set + `.AllowAnonymous()` / `.RequireAuthorization()` per route when it differs. + +## Verify + +```bash +dotnet build CleanApiStarter.slnx +dotnet format CleanApiStarter.slnx --verify-no-changes +dotnet test CleanApiStarter.slnx # Docker required for the integration tests +``` diff --git a/.claude/skills/add-migration/SKILL.md b/.claude/skills/add-migration/SKILL.md new file mode 100644 index 0000000..f6dd172 --- /dev/null +++ b/.claude/skills/add-migration/SKILL.md @@ -0,0 +1,39 @@ +--- +name: add-migration +description: Add or change the PostgreSQL schema in this codebase. Use when adding or modifying a database table, column, index, or migration in CleanApiStarter, including keeping the EF Core entity configuration in sync. +paths: database/migrations/**, src/CleanApiStarter.Api/Infrastructure/Persistence/** +--- + +# Add a database change + +`database/migrations` is the schema's single source of truth. There are no EF +migrations; the EF Core entity configurations must be kept in sync with the SQL +by hand. + +## Steps + +1. Add `database/migrations/V__short_description.sql`, numbered after the + latest file. Migrations are applied in file-name order by both the Aspire + AppHost and the integration test factory, so they must be **idempotent** + (`CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, …). + +2. Update the matching EF config in + `src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/*Configuration.cs` + (an `IEntityTypeConfiguration`), and the entity in + `src/CleanApiStarter.Api/Domain/Entities/` if columns changed. + +## Conventions + +- Timestamp columns are `TIMESTAMP WITH TIME ZONE`; all values are stored in UTC + (`DateTime.UtcNow` in code). Do not use plain `TIMESTAMP`. +- Identity tables live in `V002__create_identity_tables.sql`; application tables in + `V001`. Keep new application tables in their own `V` file. +- Entities are persistence-agnostic POCOs; mapping details (column names, lengths, + relationships, delete behaviour) belong in the EF configuration, not the entity. + +## Verify + +```bash +dotnet build CleanApiStarter.slnx +dotnet test CleanApiStarter.slnx # integration tests apply the scripts to a real container +``` diff --git a/.editorconfig b/.editorconfig index f23b7d6..5755f36 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,82 +1,82 @@ -root = true - -# All files -[*] -indent_style = space - -# Xml files -[*.xml] -indent_size = 2 - -# C# files -[*.cs] - -#### Core EditorConfig Options #### - -# Indentation and spacing -indent_size = 4 -tab_width = 4 - +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +tab_width = 4 + # New line preferences end_of_line = lf insert_final_newline = true - -#### .NET Coding Conventions #### -[*.{cs,vb}] - -# Organize usings -dotnet_separate_import_directive_groups = true -dotnet_sort_system_directives_first = true -file_header_template = unset - -# this. and Me. preferences -dotnet_style_qualification_for_event = false:silent -dotnet_style_qualification_for_field = false:silent -dotnet_style_qualification_for_method = false:silent -dotnet_style_qualification_for_property = false:silent - -# Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true:silent -dotnet_style_predefined_type_for_member_access = true:silent - -# Parentheses preferences -dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent -dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent -dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent - -# Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent - -# Expression-level preferences -dotnet_style_coalesce_expression = true:suggestion -dotnet_style_collection_initializer = true:suggestion -dotnet_style_explicit_tuple_names = true:suggestion -dotnet_style_null_propagation = true:suggestion -dotnet_style_object_initializer = true:suggestion -dotnet_style_operator_placement_when_wrapping = beginning_of_line -dotnet_style_prefer_auto_properties = true:suggestion -dotnet_style_prefer_compound_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_return = true:suggestion -dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion -dotnet_style_prefer_inferred_tuple_names = true:suggestion -dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion -dotnet_style_prefer_simplified_boolean_expressions = true:suggestion -dotnet_style_prefer_simplified_interpolation = true:suggestion - -# Field preferences -dotnet_style_readonly_field = true:warning - -# Parameter preferences -dotnet_code_quality_unused_parameters = all:suggestion - -# Suppression preferences -dotnet_remove_unnecessary_suppression_exclusions = none - -#### C# Coding Conventions #### -[*.cs] - + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + # var preferences csharp_style_var_elsewhere = false:warning csharp_style_var_for_built_in_types = false:warning @@ -84,284 +84,284 @@ csharp_style_var_when_type_is_apparent = false:warning # Prefer explicit types instead of var dotnet_diagnostic.IDE0008.severity = warning - -# Expression-bodied members -csharp_style_expression_bodied_accessors = true:silent -csharp_style_expression_bodied_constructors = false:silent -csharp_style_expression_bodied_indexers = true:silent -csharp_style_expression_bodied_lambdas = true:suggestion -csharp_style_expression_bodied_local_functions = false:silent -csharp_style_expression_bodied_methods = false:silent -csharp_style_expression_bodied_operators = false:silent -csharp_style_expression_bodied_properties = true:silent - -# Pattern matching preferences -csharp_style_pattern_matching_over_as_with_null_check = true:suggestion -csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion -csharp_style_prefer_not_pattern = true:suggestion -csharp_style_prefer_pattern_matching = true:silent -csharp_style_prefer_switch_expression = true:suggestion - -# Null-checking preferences -csharp_style_conditional_delegate_call = true:suggestion - -# Modifier preferences -csharp_prefer_static_local_function = true:warning -csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent - -# Code-block preferences -csharp_prefer_braces = true:silent -csharp_prefer_simple_using_statement = true:suggestion - -# Expression-level preferences -csharp_prefer_simple_default_expression = true:suggestion -csharp_style_deconstructed_variable_declaration = true:suggestion -csharp_style_inlined_variable_declaration = true:suggestion -csharp_style_pattern_local_over_anonymous_function = true:suggestion -csharp_style_prefer_index_operator = true:suggestion -csharp_style_prefer_range_operator = true:suggestion -csharp_style_throw_expression = true:suggestion -csharp_style_unused_value_assignment_preference = discard_variable:suggestion -csharp_style_unused_value_expression_statement_preference = discard_variable:silent - -# 'using' directive preferences -csharp_using_directive_placement = outside_namespace:silent - -#### C# Formatting Rules #### - -# New line preferences -csharp_new_line_before_catch = true -csharp_new_line_before_else = true -csharp_new_line_before_finally = true -csharp_new_line_before_members_in_anonymous_types = true -csharp_new_line_before_members_in_object_initializers = true -csharp_new_line_before_open_brace = all -csharp_new_line_between_query_expression_clauses = true - -# Indentation preferences -csharp_indent_block_contents = true -csharp_indent_braces = false -csharp_indent_case_contents = true -csharp_indent_case_contents_when_block = true -csharp_indent_labels = one_less_than_current -csharp_indent_switch_labels = true - -# Space preferences -csharp_space_after_cast = false -csharp_space_after_colon_in_inheritance_clause = true -csharp_space_after_comma = true -csharp_space_after_dot = false -csharp_space_after_keywords_in_control_flow_statements = true -csharp_space_after_semicolon_in_for_statement = true -csharp_space_around_binary_operators = before_and_after -csharp_space_around_declaration_statements = false -csharp_space_before_colon_in_inheritance_clause = true -csharp_space_before_comma = false -csharp_space_before_dot = false -csharp_space_before_open_square_brackets = false -csharp_space_before_semicolon_in_for_statement = false -csharp_space_between_empty_square_brackets = false -csharp_space_between_method_call_empty_parameter_list_parentheses = false -csharp_space_between_method_call_name_and_opening_parenthesis = false -csharp_space_between_method_call_parameter_list_parentheses = false -csharp_space_between_method_declaration_empty_parameter_list_parentheses = false -csharp_space_between_method_declaration_name_and_open_parenthesis = false -csharp_space_between_method_declaration_parameter_list_parentheses = false -csharp_space_between_parentheses = false -csharp_space_between_square_brackets = false - -# Wrapping preferences -csharp_preserve_single_line_blocks = true -csharp_preserve_single_line_statements = true - -#### Naming styles #### -[*.{cs,vb}] - -# Naming rules - -dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces -dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion -dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces -dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase - -dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion -dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters -dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase - -dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods -dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties -dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.events_should_be_pascalcase.symbols = events -dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion -dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables -dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase - -dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion -dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants -dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase - -dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion -dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters -dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase - -dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields -dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion -dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields -dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase - -dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion -dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields -dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase - -dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields -dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields -dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields -dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields -dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums -dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions -dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase - -dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion -dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members -dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase - -# Symbol specifications - -dotnet_naming_symbols.interfaces.applicable_kinds = interface -dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interfaces.required_modifiers = - -dotnet_naming_symbols.enums.applicable_kinds = enum -dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.enums.required_modifiers = - -dotnet_naming_symbols.events.applicable_kinds = event -dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.events.required_modifiers = - -dotnet_naming_symbols.methods.applicable_kinds = method -dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.methods.required_modifiers = - -dotnet_naming_symbols.properties.applicable_kinds = property -dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.properties.required_modifiers = - -dotnet_naming_symbols.public_fields.applicable_kinds = field -dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal -dotnet_naming_symbols.public_fields.required_modifiers = - -dotnet_naming_symbols.private_fields.applicable_kinds = field -dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_fields.required_modifiers = - -dotnet_naming_symbols.private_static_fields.applicable_kinds = field -dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_static_fields.required_modifiers = static - -dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum -dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types_and_namespaces.required_modifiers = - -dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method -dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = - -dotnet_naming_symbols.type_parameters.applicable_kinds = namespace -dotnet_naming_symbols.type_parameters.applicable_accessibilities = * -dotnet_naming_symbols.type_parameters.required_modifiers = - -dotnet_naming_symbols.private_constant_fields.applicable_kinds = field -dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_constant_fields.required_modifiers = const - -dotnet_naming_symbols.local_variables.applicable_kinds = local -dotnet_naming_symbols.local_variables.applicable_accessibilities = local -dotnet_naming_symbols.local_variables.required_modifiers = - -dotnet_naming_symbols.local_constants.applicable_kinds = local -dotnet_naming_symbols.local_constants.applicable_accessibilities = local -dotnet_naming_symbols.local_constants.required_modifiers = const - -dotnet_naming_symbols.parameters.applicable_kinds = parameter -dotnet_naming_symbols.parameters.applicable_accessibilities = * -dotnet_naming_symbols.parameters.required_modifiers = - -dotnet_naming_symbols.public_constant_fields.applicable_kinds = field -dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal -dotnet_naming_symbols.public_constant_fields.required_modifiers = const - -dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field -dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal -dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static - -dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field -dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected -dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static - -dotnet_naming_symbols.local_functions.applicable_kinds = local_function -dotnet_naming_symbols.local_functions.applicable_accessibilities = * -dotnet_naming_symbols.local_functions.required_modifiers = - -# Naming styles - -dotnet_naming_style.pascalcase.required_prefix = -dotnet_naming_style.pascalcase.required_suffix = -dotnet_naming_style.pascalcase.word_separator = -dotnet_naming_style.pascalcase.capitalization = pascal_case - -dotnet_naming_style.ipascalcase.required_prefix = I -dotnet_naming_style.ipascalcase.required_suffix = -dotnet_naming_style.ipascalcase.word_separator = -dotnet_naming_style.ipascalcase.capitalization = pascal_case - -dotnet_naming_style.tpascalcase.required_prefix = T -dotnet_naming_style.tpascalcase.required_suffix = -dotnet_naming_style.tpascalcase.word_separator = -dotnet_naming_style.tpascalcase.capitalization = pascal_case - -dotnet_naming_style._camelcase.required_prefix = _ -dotnet_naming_style._camelcase.required_suffix = -dotnet_naming_style._camelcase.word_separator = -dotnet_naming_style._camelcase.capitalization = camel_case - -dotnet_naming_style.camelcase.required_prefix = -dotnet_naming_style.camelcase.required_suffix = -dotnet_naming_style.camelcase.word_separator = -dotnet_naming_style.camelcase.capitalization = camel_case - -dotnet_naming_style.s_camelcase.required_prefix = s_ -dotnet_naming_style.s_camelcase.required_suffix = -dotnet_naming_style.s_camelcase.word_separator = -dotnet_naming_style.s_camelcase.capitalization = camel_case - + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:silent + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_pattern_local_over_anonymous_function = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:suggestion +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + diff --git a/.gitignore b/.gitignore index 0e2fcbc..aa9d4f9 100644 --- a/.gitignore +++ b/.gitignore @@ -403,3 +403,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml .DS_Store + +# Claude Code local settings (skills under .claude/skills/ are tracked) +.claude/settings.local.json diff --git a/.template.config/template.json b/.template.config/template.json index 970f2ba..daf08a7 100644 --- a/.template.config/template.json +++ b/.template.config/template.json @@ -38,11 +38,14 @@ "**/.DS_Store", "**/bin/**", "**/obj/**", + ".claude/settings.local.json", "artifacts/**", ".github/workflows/release.yml", ".github/workflows/template.yml", "CleanApiStarter.Template.csproj", "CONTRIBUTING.md", + "adr/**", + "docs/**", "scripts/install-template.sh" ] } diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index e92b9af..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,181 +0,0 @@ -# Agent Instructions - -This repository is a Clean Architecture API starter template named `CleanApiStarter`. - -## Naming - -- Use `CleanApiStarter` for solution, project, assembly, and namespace naming. -- Do not introduce `CleanArchitecture` namespaces or assembly names. -- Keep project names fully qualified: - - `CleanApiStarter.Api` - - `CleanApiStarter.Application` - - `CleanApiStarter.Configuration` - - `CleanApiStarter.Domain` - - `CleanApiStarter.Infrastructure` - - `CleanApiStarter.AppHost` - - `CleanApiStarter.AspNetCore` - - `CleanApiStarter.UnitTests` - -## Solution Structure - -- Keep clean architecture application layers under the `/src/` solution folder: - - API - - Application - - Configuration - - Domain - - Infrastructure -- Keep Aspire/runtime support projects under the `/src/Common/` solution folder in `CleanApiStarter.slnx`: - - AppHost - - AspNetCore -- Keep database scripts under top-level `database/migrations`. -- Keep important root files in solution items, including `README.md`, `docker-compose.yml`, `Directory.Build.props`, `Directory.Packages.props`, `.editorconfig`, `.gitignore`, and `global.json`. - -## Clean Architecture Rules - -- Dependencies point inward: - - `Domain` references nothing. - - `Application` references `Domain`. - - `Configuration` references no application layers and contains only plain options classes plus options registration helpers. - - `Infrastructure` references `Application` and `Configuration`. - - `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. -- Keep repository interfaces in `Application`, not `Domain`. -- Keep database implementation details in `Infrastructure`. -- Keep domain models persistence-agnostic. Persistence mapping details belong in Infrastructure EF Core configuration. -- Use `required` for non-null required scalar properties on DTOs and domain entities. Do not use `= string.Empty` only to satisfy nullable reference type warnings. -- Keep collection properties initialized with `= []`. -- Keep EF navigation properties as `= null!` when EF is responsible for materializing them. -- Use nullable types such as `string?` or `DateTime?` only for genuinely optional values. -- Use EF Core through `ApplicationDbContext` for application persistence. Do not reintroduce Dapper for the default template data access path. -- Keep `ApplicationDbContext` in `CleanApiStarter.Infrastructure/Persistence`, not in `Identity`. The context owns both application entities and Identity storage, so it is a persistence concern. -- Keep EF Core fluent API entity maps in `CleanApiStarter.Infrastructure/Persistence/Configuration` using `IEntityTypeConfiguration`. Do not put entity mapping logic directly inside `ApplicationDbContext` unless there is a very small one-off reason. -- Keep Identity-specific classes, such as `ApplicationUser` and auth services, under `CleanApiStarter.Infrastructure/Identity`. - -## API and Application Conventions - -- Organize the `Application` project by feature. Put feature-specific contracts, DTOs, and application services under folders such as `Application/Features/Projects` or `Application/Features/Auth`. -- Do not create generic `Application/Services` or `Application/Models` folders for feature-specific code. -- Keep cross-cutting application abstractions under `Application/Common`, for example `Application/Common/Interfaces`. -- Return single resources as their DTO object directly. -- Use `ArrayResult` instead of returning raw arrays from non-paginated list endpoints. -- Use `PaginatedQuery` for paginated request parameters and return `PaginatedResult` directly for paginated collection responses. -- Use FluentValidation for request validation. Put validators beside feature request models in `Application`, and rely on the shared Minimal API validation endpoint filter in `CleanApiStarter.AspNetCore`. FluentValidation failures should return `422 Unprocessable Entity`. -- Do not use DataAnnotations for application request validation. -- Use Scalar, not Swagger/Swashbuckle. -- Use the .NET 10 API versioning/OpenAPI setup from the Microsoft .NET blog: - - `Asp.Versioning.Http` v10 - - `Asp.Versioning.Mvc.ApiExplorer` v10 - - `Asp.Versioning.OpenApi` - - `builder.Services.AddApiVersioning(...).AddApiExplorer(...).AddOpenApi();` - - `app.MapOpenApi().WithDocumentPerVersion();` - - `app.MapScalarApiReference(...)` configured from `app.DescribeApiVersions()` -- Do not add `Swashbuckle.AspNetCore`. -- Cancellation tokens are explicit: - - Do not use `CancellationToken cancellationToken = default` in service or repository contracts. - - API actions should accept `CancellationToken cancellationToken` and pass it through. -- Use explicit local types. The `.editorconfig` prefers explicit types over `var`. -- Use project-level `GlobalUsings.cs`; avoid adding file-level `using` directives unless there is a very specific reason. - -## Database and Aspire - -- Use a single Postgres database: `postgres`. -- Do not create or reference a separate feature-specific database. -- AppHost should expose the API connection from the `postgres` resource, and application settings should read it through `ConnectionStrings:Postgres`. -- `docker-compose.yml` should use `POSTGRES_DB=postgres`. -- Database schema scripts live in `database/migrations`, for example: - - `database/migrations/V001__create_projects_and_tasks_tables.sql` -- Do not add API startup database initialization such as `DbInitializer` or DbUp calls. Aspire/Docker init scripts own local schema creation. -- EF Core is used for application data access and Identity storage, but schema creation still belongs to the SQL scripts in `database/migrations`. -- Docker Postgres init scripts run only on first volume creation. If scripts need to replay, delete the old volume. -- This repo uses `postgres:latest`. Because Postgres 18+ expects the data volume mounted at `/var/lib/postgresql`, do not mount the volume at `/var/lib/postgresql/data`. -- In Aspire, use a server resource name that does not conflict with the database resource name, for example: - - server resource: `postgres-server` - - database resource: `postgres` -- Aspire Postgres should use a volume mounted at `/var/lib/postgresql`. - -## ASP.NET Core Defaults - -- Keep `CleanApiStarter.AspNetCore`. -- It centralizes Aspire-friendly runtime defaults: - - OpenTelemetry traces, metrics, logs, and OTLP export - - problem-details exception handling - - `/version` endpoint - - health endpoints - - service discovery - - default HTTP client resilience - - security defaults such as removing the Kestrel `Server` response header -- API `Program.cs` should stay small and call: - - `builder.AddAspNetCoreDefaults();` - - `app.UseAspNetCoreDefaults();` - - `app.MapDefaultEndpoints();` -- Shared middleware such as HTTP request logging belongs in `CleanApiStarter.AspNetCore`, not duplicated inside each API project. -- Keep OpenTelemetry logs configured to include scopes, formatted messages, and parsed state values so structured message-template properties show up in Aspire. -- Responses should include `X-Request-ID` with the current trace id, configured centrally in `CleanApiStarter.AspNetCore`. -- Response compression should be configured centrally in `CleanApiStarter.AspNetCore` with Brotli and gzip providers. -- Use structured logging message templates instead of interpolated log strings. Prefer stable property names like `{ProjectId}`, `{TaskId}`, and `{UserId}`. -- Register root settings once with `AddAppSettings(builder.Configuration)`, then inject `AppSettings` directly when services need configuration values. -- Do not add generic options registration helpers until the template has multiple real options sections that need them. -- Do not create a broad `Shared` project. Keep cross-project settings in `CleanApiStarter.Configuration`. -- Keep dependency injection validation enabled with `ValidateOnBuild` and `ValidateScopes`. - -## API Style - -- Prefer Minimal APIs for this template. -- Keep `Program.cs` small by placing route groups in endpoint group classes under version folders such as `Api/Endpoints/V1/Projects.cs`. -- Endpoint group classes should implement `IEndpointGroup` from `CleanApiStarter.AspNetCore` and be mapped through `app.MapEndpoints(Assembly.GetExecutingAssembly());`. -- Use built-in Minimal API mapping methods with explicit `.WithName(...)`; do not add custom `MapGet`/`MapPost` overloads that shadow framework methods. -- API versions are selected with the optional `X-Api-Version` request header. Missing versions default to v1. -- Endpoint groups should declare `MajorVersion` to match their folder, for example `V1` uses `1` and `V2` uses `2`. -- Endpoint names must be globally unique across versions. Prefer names suffixed with the version, such as `GetProjectsV1` and `GetProjectsV2`. -- Do not reintroduce MVC controllers unless the template intentionally changes direction. - -## Authentication - -- Authentication is API-first: - - clients obtain a Google ID token - - `POST /api/auth/google` validates it - - the API issues its own JWT -- Keep JWT bearer authentication setup in `CleanApiStarter.AspNetCore`. -- Keep local user/role storage in ASP.NET Core Identity under `CleanApiStarter.Infrastructure`. -- Do not use cookies as the default API auth mechanism. -- Keep the development Google login helper page unversioned so it can be opened directly in a browser. -- Protected API calls should send `Authorization: Bearer `. Send `X-Api-Version` only when selecting a non-default API version. - -## Packages - -- Manage versions centrally in `Directory.Packages.props`. -- Keep `PackageVersion` items sorted alphabetically by `Include`. -- Do not add package versions directly in individual `.csproj` files. - -## Template Packaging - -- This repository is also the `dotnet new` template source. -- Keep template metadata in `.template.config/template.json`. -- Keep NuGet template package metadata in `CleanApiStarter.Template.csproj`. -- Use `dotnet pack` and `dotnet nuget push` for template packaging and publishing. Do not use `nuget pack`, `nuget.exe`, or Mono. -- Use `scripts/install-template.sh` to pack and install the local template. -- Keep repo-only template packaging scripts excluded from generated template output. -- Keep CodeQL security scanning in `.github/workflows/codeql.yml`, and allow generated projects to inherit it. -- Keep release publishing triggered by GitHub Release publication with `vX.Y.Z` tags, not manual version inputs. - -## Testing - -- Use xUnit v3, AutoFixture.xUnit3, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly for unit tests. -- Reusable test helpers belong in `CleanApiStarter.Tests`. -- Application unit tests should reference `CleanApiStarter.Tests` instead of duplicating common test setup. -- API integration tests use MSTest and Testcontainers for Postgres. -- API integration tests should keep real JWT bearer authentication active. Generate test JWTs from `appsettings.Testing.json` instead of replacing authentication with a fake scheme. -- API integration tests should start a Postgres Testcontainer and apply SQL scripts from `database/migrations`. -- Keep test app settings in `appsettings.Testing.json`. -- Test methods must follow the naming pattern `UnitOfWork_StateUnderTest_ExpectedBehavior`. -- Tests must follow AAA format with explicit `// Arrange`, `// Act`, and `// Assert` sections. - -## Verification - -- After structural or package changes, run: - -```bash -dotnet restore CleanApiStarter.slnx -dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal -``` - -- Aspire AppHost builds may need to run outside a sandbox because the Aspire SDK touches local runtime/process resources. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6681dac..109158d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,18 @@ # Contributing to CleanApiStarter -Thanks for your interest in contributing. This repository is both a working reference application and a packaged `dotnet new` template, so changes are verified in both forms. +Thanks for your interest in contributing. This repository is both a working +reference application and a packaged `dotnet new` template, so changes are +verified in both forms. The whole application is a single +`CleanApiStarter.Api` project with boundaries enforced by an analyzer; see +[`adr/`](adr/) and [docs/architecture](docs/architecture/clean-architecture-and-vertical-slices.md) +for the reasoning. ## Prerequisites -- **.NET SDK** matching [global.json](global.json) (10.0.2xx; `rollForward: latestFeature` applies). -- **Docker** — required to run the API integration tests (Testcontainers) and local PostgreSQL. -- No IDE requirement; the solution file is [CleanApiStarter.slnx](CleanApiStarter.slnx). +- **.NET SDK** matching [global.json](global.json) (10.0.2xx; `rollForward: + latestFeature` applies). +- **Docker** — required for the integration tests (Testcontainers) and local + PostgreSQL. ## Getting started @@ -17,58 +23,86 @@ dotnet build CleanApiStarter.slnx dotnet test CleanApiStarter.slnx ``` -To run the API locally, use the Aspire AppHost (starts PostgreSQL with the schema scripts applied, plus pgAdmin): +To run the API locally via Aspire (starts PostgreSQL with the schema applied, plus +pgAdmin): ```bash dotnet run --project src/CleanApiStarter.AppHost ``` -Alternatively, start PostgreSQL with `docker compose up -d` and run `src/CleanApiStarter.Api` directly. - -See the [README](README.md) for the solution layout and layer dependency rules. The short version: dependencies point inward — `Domain` references nothing, `Application` references only `Domain`, and web/persistence concerns stay in `Api`, `AspNetCore`, and `Infrastructure`. +Alternatively, start PostgreSQL with `docker compose up -d` and run +`src/CleanApiStarter.Api` directly. ## Making changes -1. Branch from `main` (the convention here is `feature/-short-description`). +1. Branch from `main` (convention: `feature/-short-description`). 2. Keep changes focused; unrelated refactoring belongs in its own PR. 3. Open a pull request against `main`. CI must pass before review. -### What CI enforces +Working in Claude Code? This repo ships project skills in +[`.claude/skills/`](.claude/skills/) — run `/add-endpoint` or `/add-migration` +(or let Claude apply them automatically) for step-by-step, convention-correct +recipes. -The same checks run on every PR ([build.yml](.github/workflows/build.yml), [template.yml](.github/workflows/template.yml), [codeql.yml](.github/workflows/codeql.yml)): +### What CI enforces -- **Formatting** — `dotnet format CleanApiStarter.slnx --verify-no-changes`. Run `dotnet format CleanApiStarter.slnx` before pushing; style rules live in [.editorconfig](.editorconfig). -- **Build and tests** — the full suite, including the Testcontainers-based integration tests. -- **Template verification** — the template is packed, installed, and instantiated to confirm generated projects still build. +The same checks run on every PR ([build.yml](.github/workflows/build.yml), +[template.yml](.github/workflows/template.yml), +[codeql.yml](.github/workflows/codeql.yml)): + +- **Formatting** — `dotnet format CleanApiStarter.slnx --verify-no-changes`. Run + `dotnet format CleanApiStarter.slnx` before pushing; style rules live in + [.editorconfig](.editorconfig). +- **Build and tests** — the full suite, including the Testcontainers-based + integration tests. +- **Template verification** — the template is packed, installed, and instantiated + to confirm generated projects build. - **CodeQL** security scanning. ## Tests -- Unit tests: `tests/CleanApiStarter.Application.UnitTests` (xUnit v3, AutoFixture, NSubstitute, Shouldly). -- Integration tests: `tests/CleanApiStarter.Api.IntegrationTests` (xUnit v3 against a real PostgreSQL container; Docker must be running). -- Shared test infrastructure (the `ApiApplicationFactory` class fixture, AutoFixture attributes) lives in `tests/CleanApiStarter.Tests`. +- Unit tests: `tests/CleanApiStarter.UnitTests` (xUnit v3, AutoFixture, + NSubstitute, Shouldly). +- Integration tests: `tests/CleanApiStarter.IntegrationTests` (xUnit v3 + against a real PostgreSQL container; Docker must be running). +- Shared test infrastructure (the `ApiApplicationFactory` class fixture) lives in + `tests/CleanApiStarter.TestUtilities`. -New behavior needs tests. Integration tests share one database per test class, so isolate through unique user ids or resource names rather than assuming a fresh schema. +New behavior needs tests. The integration test factory is an `IClassFixture`, so +all tests in a class share one database — isolate through unique user ids or +resource names rather than assuming a fresh schema. -For a local coverage report: +For a local coverage report: `./scripts/test-coverage.sh`. -```bash -./scripts/test-coverage.sh -``` +## Boundary enforcement + +Dependency direction is enforced by [NsDepCop](https://github.com/realvizu/NsDepCop): +`src/CleanApiStarter.Api/config.nsdepcop` lists the illegal namespace directions +(for example `Domain ✗→ Infrastructure`, `Features ✗→ Infrastructure`), and +`WarningsAsErrors=NSDEPCOP01` makes a violation a build error. Update +`config.nsdepcop` when you add a layer or rule. `AspNetCoreDefaults` is kept +application-agnostic — don't introduce a dependency from it onto the Api's +settings or features. ## Database changes -The SQL scripts in `database/migrations` are the schema's source of truth; the EF Core entity configurations in `src/CleanApiStarter.Infrastructure/Persistence/Configuration` must be kept in sync with them manually. When changing either, update both. +`database/migrations` is the schema's source of truth. The EF Core entity +configurations in `src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration` +must be kept in sync with the SQL by hand. When you change one, change the other. Conventions: -- Timestamp columns are `TIMESTAMP WITH TIME ZONE`; all values are stored in UTC (`DateTime.UtcNow` in code). -- Migration files are named `V__description.sql` and are applied in file-name order by both the AppHost and the integration test factory. +- Timestamp columns are `TIMESTAMP WITH TIME ZONE`; all values are stored in UTC + (`DateTime.UtcNow` in code). +- Migration files are named `V__description.sql` and applied in file-name + order by both the AppHost and the integration test factory. - Migrations must be idempotent (`CREATE TABLE IF NOT EXISTS`, etc.). ## Template changes -If your change affects the project structure, file names, or anything under [.template.config/template.json](.template.config/template.json), verify the template output locally: +If a change affects project structure, file names, or +[.template.config/template.json](.template.config/template.json), verify the +template output locally: ```bash dotnet pack CleanApiStarter.Template.csproj --configuration Release --output artifacts @@ -77,8 +111,12 @@ dotnet new clean-api-starter -n DemoProduct -o /tmp/DemoProduct dotnet build /tmp/DemoProduct ``` -Repository-only files (CI release workflows, this guide, install scripts) must be listed in the `exclude` section of `template.json` so they don't ship inside generated projects. +Repository-only files (this guide, `adr/`, `docs/`, release/template CI workflows, +install scripts) must be listed in the `exclude` section of `template.json` so they +don't ship inside generated projects. ## Reporting issues -Open a GitHub issue with reproduction steps, expected vs. actual behavior, and your environment (OS, .NET SDK version). For security vulnerabilities, please do not open a public issue — contact the maintainer directly instead. +Open a GitHub issue with reproduction steps, expected vs. actual behavior, and +your environment (OS, .NET SDK version). For security vulnerabilities, please do +not open a public issue — contact the maintainer directly instead. diff --git a/CleanApiStarter.Template.csproj b/CleanApiStarter.Template.csproj index f3497b3..d82b175 100644 --- a/CleanApiStarter.Template.csproj +++ b/CleanApiStarter.Template.csproj @@ -24,7 +24,7 @@ diff --git a/CleanApiStarter.slnx b/CleanApiStarter.slnx index acb877a..668c2dd 100644 --- a/CleanApiStarter.slnx +++ b/CleanApiStarter.slnx @@ -1,44 +1,45 @@ - - - - - - - + + + + + + + + + + + + + + - - - - - - - - - - - + + + + + - - - + + + diff --git a/Directory.Build.props b/Directory.Build.props index f2acb86..d2894e9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,10 +1,10 @@ - - - - net10.0 - true - - enable - enable - + + + + net10.0 + true + + enable + enable + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index 5b5aa04..5d6efd1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,6 +37,7 @@ + diff --git a/README.md b/README.md index a8c68c4..72cdd28 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,43 @@ # CleanApiStarter -`CleanApiStarter` is a Clean Architecture API starter template for .NET 10, ASP.NET Core Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API versioning, FluentValidation, Scalar, EF Core, and automated tests. - -The project is intentionally both a template and a working reference application. The sample domain is project management: authenticated users can create projects, manage project tasks, filter tasks by status, and complete tasks. +`CleanApiStarter` is a Clean Architecture API starter for .NET 10, ASP.NET Core +Minimal APIs, Aspire, PostgreSQL, OpenTelemetry, JWT authentication, API +versioning, FluentValidation, Scalar, EF Core, and automated tests. + +The whole application lives in a single `CleanApiStarter.Api` project, organized by +feature and layer folders, with the Clean Architecture dependency rule enforced at +compile time by [NsDepCop](https://github.com/realvizu/NsDepCop) rather than by +separate projects. The sample domain is project management: authenticated users +create projects, manage tasks, filter tasks by status, and complete them. + +## Architecture in brief + +CleanApiStarter treats architecture as three separate decisions rather than one: + +- **Feature organization** — *where do I find the code?* Related code for a + capability lives together (vertical slices), not scattered across technical + folders. +- **Dependency management** — *what may depend on what?* Clean Architecture is, at + its core, about dependency direction: business logic stays free of + infrastructure, and dependencies point inward. Here that rule is enforced at + build time by an analyzer (NsDepCop) rather than by splitting the app into many + projects. +- **Code reuse** — *is logic consistent across features?* Share what is genuinely + shared (the domain model, infrastructure); duplicate only slice-level glue + (request/response, validators, handlers) instead of coupling features through the + wrong abstraction. + +Project count is an implementation detail, not an architectural principle. For the +full reasoning see +[docs/architecture](docs/architecture/clean-architecture-and-vertical-slices.md); +for how the structure evolved, see the [decision records](adr/). ## Features -- Clean Architecture solution structure with separate `Api`, `Application`, `Domain`, `Infrastructure`, `Configuration`, and shared ASP.NET Core defaults projects. +- Single application project with feature-centric organization, plus an + application-agnostic `AspNetCoreDefaults` project and an Aspire `AppHost`. +- Compile-time boundary enforcement via NsDepCop (`config.nsdepcop`, + `WarningsAsErrors=NSDEPCOP01`): a forbidden dependency fails the build. - Minimal APIs organized by endpoint group and API version folders. - Header-based API versioning with optional `X-Api-Version`; missing versions default to v1. - Versioned OpenAPI documents shown with Scalar. @@ -23,10 +54,7 @@ The project is intentionally both a template and a working reference application - Response compression with Brotli and gzip. - FluentValidation endpoint validation returning `422 Unprocessable Entity`. - Result wrappers for collection endpoints: `PaginatedResult` and `ArrayResult`. -- xUnit v3 unit tests with AutoFixture, AutoFixture.AutoNSubstitute, NSubstitute, and Shouldly. -- xUnit v3 API integration tests with Testcontainers for PostgreSQL. -- Local coverage script that generates an HTML report with ReportGenerator. -- GitHub Actions CI with build, test, template verification, and CodeQL security scanning. +- xUnit v3 unit and Testcontainers-backed integration tests. ## Solution Layout @@ -36,565 +64,62 @@ CleanApiStarter │ └── migrations ├── scripts ├── src -│ ├── CleanApiStarter.Api -│ ├── CleanApiStarter.Application -│ ├── CleanApiStarter.Domain -│ ├── CleanApiStarter.Infrastructure +│ ├── CleanApiStarter.Api ← the whole application +│ │ ├── config.nsdepcop ← enforced dependency rules +│ │ ├── Common ← shared kernel (paged results, IUser) +│ │ ├── Domain ← entities (no outward dependencies) +│ │ ├── Features ← Auth, Projects/Tasks — one file per operation +│ │ │ (endpoint + request + validator + handler) +│ │ ├── Infrastructure ← DbContext, EF config, repositories, identity +│ │ ├── Configuration ← settings classes + options registration +│ │ ├── Services ← composition / web wiring (CurrentUser) +│ │ └── Program.cs │ └── Common -│ ├── CleanApiStarter.AppHost -│ ├── CleanApiStarter.AspNetCore -│ └── CleanApiStarter.Configuration +│ ├── CleanApiStarter.AppHost ← Aspire orchestration +│ └── CleanApiStarter.AspNetCoreDefaults ← reusable, app-agnostic web/runtime defaults └── tests - ├── CleanApiStarter.Api.IntegrationTests - ├── CleanApiStarter.Application.UnitTests - └── CleanApiStarter.Tests -``` - -Layer dependencies point inward: - -- `Domain` references nothing. -- `Application` references `Domain`. -- `Infrastructure` references `Application` and `Configuration`. -- `Api` composes `Application`, `Infrastructure`, `Configuration`, and `AspNetCore`. -- `Configuration` contains plain settings classes and options registration. -- `AspNetCore` contains reusable web/runtime defaults. -- `AppHost` contains Aspire orchestration. - -## Requirements - -- .NET SDK `10.0.203` or compatible latest feature SDK -- Docker Desktop -- Google Chrome, only for the local coverage script opening the HTML report - -The SDK is pinned in `global.json`: - -```json -{ - "sdk": { - "version": "10.0.203", - "rollForward": "latestFeature" - } -} -``` - -## Use As A Template - -Install the template package from NuGet: - -```bash -dotnet new install CleanApiStarter.Template -``` - -Create a new solution: - -```bash -dotnet new clean-api-starter -n MyProduct -``` - -The template replaces `CleanApiStarter` in solution, project, file, and namespace names. For example, `CleanApiStarter.Api` becomes `MyProduct.Api`. - -While developing the template locally, run: - -```bash -scripts/install-template.sh -``` - -That script: - -- uninstalls the previous local template package -- packs the current repo with `CleanApiStarter.Template.csproj` -- installs the generated local `.nupkg` -- deletes the temporary package from `artifacts` - -Then create a local test solution: - -```bash -dotnet new clean-api-starter -n DemoProduct -``` - -## Run With Aspire - -```bash -dotnet run --project src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj -``` - -Aspire starts: - -- the API -- a PostgreSQL container using `postgres:latest` -- pgAdmin -- the Aspire dashboard - -Open the Aspire dashboard URL printed in the terminal. Use it to inspect resources, logs, traces, metrics, health, and PostgreSQL activity. - -The AppHost configures PostgreSQL like this: - -- server resource: `postgres-server` -- database resource: `postgres` -- data volume: `clean-api-starter-postgres-data` -- volume mount: `/var/lib/postgresql` -- init scripts: `database/migrations` - -PostgreSQL init scripts run only when the database volume is first created. If you change scripts and want them replayed locally, delete the old Docker volume. - -## Run With Docker Compose - -If you want only PostgreSQL without Aspire: - -```bash -docker compose up -d -dotnet run --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj -``` - -`docker-compose.yml` starts one database named `postgres` and mounts `database/migrations` into the Postgres init directory. - -## Database - -Database scripts live here: - -```text -database/migrations -├── V001__create_projects_and_tasks_tables.sql -└── V002__create_identity_tables.sql -``` - -The application uses EF Core through `ApplicationDbContext`, but local schema creation is owned by the SQL scripts. There are no DbUp calls or API startup database initializers. - -`ApplicationDbContext` lives in: - -```text -src/CleanApiStarter.Infrastructure/Persistence -``` - -EF Core mappings live in: - -```text -src/CleanApiStarter.Infrastructure/Persistence/Configuration -``` - -## API Style - -The API uses Minimal APIs. `Program.cs` stays small and delegates endpoint registration to endpoint group classes. - -Endpoint groups live under version folders: - -```text -src/CleanApiStarter.Api/Endpoints/V1 -src/CleanApiStarter.Api/Endpoints/V2 -``` - -Each endpoint group implements `IEndpointGroup` from `CleanApiStarter.AspNetCore` and is discovered through: - -```csharp -app.MapEndpoints(Assembly.GetExecutingAssembly()); -``` - -Endpoint names are globally unique across versions, for example: - -```text -GetProjectsV1 -GetProjectsV2 -``` - -## API Versioning - -API versioning uses header-based version selection: - -```http -X-Api-Version: 1.0 -``` - -The header is optional. Requests without `X-Api-Version` use v1. - -The project uses the .NET 10 API versioning/OpenAPI package setup: - -- `Asp.Versioning.Http` -- `Asp.Versioning.Mvc.ApiExplorer` -- `Asp.Versioning.OpenApi` -- `Microsoft.AspNetCore.OpenApi` - -OpenAPI documents are generated per version and displayed in Scalar. - -## Scalar - -This project uses Scalar instead of Swagger/Swashbuckle. - -When running in Development, OpenAPI and Scalar are mapped by the API: - -- versioned OpenAPI documents -- Scalar API reference with version selection - -## Authentication - -Authentication is API-first. - -The intended production flow is: - -1. A client obtains a Google ID token. -2. The client sends it to `POST /api/auth/google`. -3. The API validates the Google ID token. -4. The API creates or updates the local ASP.NET Core Identity user. -5. The API issues its own JWT. -6. Protected API calls use: - -```http -Authorization: Bearer -``` - -The API does not use cookies as the default authentication mechanism. - -### Google Client ID - -Create an OAuth client in Google Cloud Console and set the client id with user secrets: - -```bash -dotnet user-secrets set "Authentication:Google:ClientId" "" --project src/CleanApiStarter.Api/CleanApiStarter.Api.csproj -``` - -For local browser testing, open the development helper page: - -```text -https://localhost:7285/auth/google-login -``` - -The helper page signs in with Google, calls the API token endpoint, displays the API JWT, and includes a copy button. - -## Authorization - -Protected endpoint groups call `RequireAuthorization()` once at the group level, so individual project/task endpoints do not need repeated authorization declarations. - -Project authorization rules are implemented in the application service and repository queries: - -- users can only see projects they belong to -- users can only see and manage tasks inside projects they belong to -- project deletion is owner-only - -## Application Features - -The reference feature is project and task management. - -Projects: - -- create project -- list current user's projects -- get project by id -- delete project as owner - -Tasks: - -- create task under a project -- list tasks with `limit` and `offset` -- filter tasks by status -- get task by id -- update task -- complete task -- delete task - -The application project is organized by feature: - -```text -src/CleanApiStarter.Application/Features/Auth -src/CleanApiStarter.Application/Features/Projects -``` - -Cross-cutting abstractions live under: - -```text -src/CleanApiStarter.Application/Common -``` - -## Response Shapes - -Single-resource endpoints return the resource DTO directly. - -Paginated endpoints return `PaginatedResult`: - -```json -{ - "items": [], - "limit": 20, - "offset": 0, - "totalCount": 0, - "hasPreviousPage": false, - "hasNextPage": false -} -``` - -Non-paginated collection endpoints should return `ArrayResult`: - -```json -{ - "items": [], - "count": 0 -} -``` - -## Validation - -Request validation uses FluentValidation, not DataAnnotations. - -Validators live beside request models in the `Application` project. The shared Minimal API validation filter lives in `CleanApiStarter.AspNetCore`. - -Validation failures return: - -```http -422 Unprocessable Entity -``` - -## Configuration - -Configuration classes live in `CleanApiStarter.Configuration`. - -The root configuration object is: - -```csharp -AppSettings + ├── CleanApiStarter.UnitTests + ├── CleanApiStarter.IntegrationTests + └── CleanApiStarter.TestUtilities ← shared test factory + attributes ``` -It includes: - -- `ConnectionStrings.Postgres` -- `Authentication.Google.ClientId` -- `Authentication.Jwt.Issuer` -- `Authentication.Jwt.Audience` -- `Authentication.Jwt.SigningKey` -- `Authentication.Jwt.ExpirationMinutes` - -The API registers settings once: - -```csharp -builder.Services.AddAppSettings(builder.Configuration); -``` - -Services can inject `AppSettings` directly. - -## Observability - -`CleanApiStarter.AspNetCore` centralizes runtime defaults: - -- OpenTelemetry traces -- OpenTelemetry metrics -- OpenTelemetry logs -- OTLP export for Aspire -- structured log formatting -- HTTP request logging -- health checks -- service discovery -- HTTP client resilience -- problem details -- response compression -- request id header -- Kestrel `Server` header removal - -When the API runs under Aspire, inspect the dashboard for: +Dependency direction (enforced by `config.nsdepcop`): -- API request traces -- Npgsql database traces -- structured logs -- request logs with `UserId` -- runtime metrics -- resource health +- `Domain` depends on nothing else in the app — not `Features`, not `Infrastructure`. +- `Features` holds one self-contained slice per operation (endpoint + request + + validator + handler). A handler may not depend on `Infrastructure`; it talks to + abstractions (`IProjectRepository`, `IAuthService`) that `Infrastructure` + implements and DI wires up. +- `Infrastructure`, `Configuration`, `Services`, and `Program.cs` form the + composition root. +- `AspNetCoreDefaults` is an application-agnostic platform project — it holds no + knowledge of the application's settings; the Api binds configuration-dependent + options (such as JWT validation) itself. -Every response includes: +A violation — for example `using` an `Infrastructure` type from `Domain` — fails +the build with `error NSDEPCOP01`. -```http -X-Request-ID: -``` - -Use this value to correlate client responses with logs and traces. - -## Health And Version Endpoints - -The shared ASP.NET Core defaults map: - -```text -/health -/alive -/version -``` - -`/version` exposes the running application version. - -## Testing - -### Unit Tests - -Application unit tests use: - -- xUnit v3 -- AutoFixture.xUnit3 -- AutoFixture.AutoNSubstitute -- NSubstitute -- Shouldly - -Common test helpers live in: - -```text -tests/CleanApiStarter.Tests -``` - -Current reusable helpers: - -- `AutoNSubstituteDataAttribute` -- `ApiApplicationFactory` - -Test names follow: - -```text -UnitOfWork_StateUnderTest_ExpectedBehavior -``` - -Tests use AAA sections: - -```csharp -// Arrange -// Act -// Assert -``` - -Run unit tests: - -```bash -dotnet test tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj -``` - -### API Integration Tests - -API integration tests use: - -- xUnit v3 -- Microsoft.AspNetCore.Mvc.Testing -- Testcontainers for PostgreSQL -- Shouldly - -The integration test factory starts a real Postgres container, applies scripts from `database/migrations`, boots the API through `WebApplicationFactory`, and creates real JWTs from `appsettings.Testing.json`. - -The API integration tests keep JWT bearer authentication active. They do not replace authentication with a fake scheme. - -Run integration tests: - -```bash -dotnet test tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj -``` - -Run all tests: - -```bash -dotnet test CleanApiStarter.slnx -``` - -## Coverage - -Generate coverage and open the HTML report in Chrome: - -```bash -scripts/test-coverage.sh -``` - -The script: - -- deletes the previous `artifacts/coverage` folder -- restores local .NET tools -- runs tests with `XPlat Code Coverage` -- generates an HTML report with ReportGenerator -- opens the report in Google Chrome - -Coverage output: - -```text -artifacts/coverage/report/index.html -``` - -## Build - -Restore and build: - -```bash -dotnet restore CleanApiStarter.slnx -dotnet build CleanApiStarter.slnx --no-restore /nr:false -v:minimal -``` - -## CI - -GitHub Actions workflows live in `.github/workflows`: - -- `build.yml` restores, builds, and tests the repository on pull requests and pushes to `main`. -- `codeql.yml` runs CodeQL static security analysis on pull requests, pushes to `main`, and weekly on Monday. -- `template.yml` packs the template, installs it locally, creates a sample solution, then restores, builds, and tests the generated output. -- `release.yml` publishes the template to NuGet when a GitHub Release is published with a tag such as `v1.0.0`. - -## Package Management - -Package versions are managed centrally in: - -```text -Directory.Packages.props -``` - -Keep package versions sorted alphabetically by `Include`. Do not add package versions directly to individual project files. - -## Coding Conventions - -- Use explicit local types. -- Use project-level `GlobalUsings.cs`. -- Keep cancellation tokens explicit. Do not use `CancellationToken cancellationToken = default` in service or repository contracts. -- Use `required` and `init` for required non-null DTO and domain properties. -- Use nullable types only for genuinely optional values. -- Use structured logging message templates instead of interpolated log strings. -- Keep repository interfaces in `Application`. -- Keep repository implementations and EF Core details in `Infrastructure`. -- Keep domain models persistence-agnostic. -- Do not reintroduce Dapper for the default data access path. -- Do not reintroduce MVC controllers unless the template intentionally changes direction. -- Do not add Swashbuckle; use Scalar. - -## Troubleshooting +## Requirements -### Aspire certificate errors +- .NET SDK `10.0.203` or a compatible latest-feature SDK (pinned in `global.json`). +- Docker Desktop (for integration tests and local PostgreSQL). -If Aspire logs an `UntrustedRoot` error or reports that no trusted development certificate exists, trust the local ASP.NET Core development certificate: +## Getting started ```bash -dotnet dev-certs https --check --trust +dotnet run --project src/CleanApiStarter.AppHost # API + PostgreSQL via Aspire +# or +docker compose up -d && dotnet run --project src/CleanApiStarter.Api ``` -If needed, reset and trust again: +Run the tests with `dotnet test CleanApiStarter.slnx`. -```bash -dotnet dev-certs https --clean -dotnet dev-certs https --trust -dotnet dev-certs https --check --trust -``` - -On macOS, accept the Keychain prompt and enter your password if requested. - -### PostgreSQL init scripts did not run - -Postgres init scripts only run when the data directory is created. Delete the existing volume and start again. - -For Docker Compose: +To generate a new project from the published template: ```bash -docker compose down -v -docker compose up -d -``` - -For Aspire, delete the `clean-api-starter-postgres-data` Docker volume. - -### Postgres latest and data volumes - -This template uses `postgres:latest`. PostgreSQL 18+ expects data mounted at: - -```text -/var/lib/postgresql -``` - -Do not mount the volume at: - -```text -/var/lib/postgresql/data +dotnet new clean-api-starter -n MyApi ``` -## License +## Contributing -This project is licensed under the GNU General Public License v3.0. See `LICENSE` for details. +See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/adr/ADR-001.md b/adr/ADR-001.md new file mode 100644 index 0000000..ed7df04 --- /dev/null +++ b/adr/ADR-001.md @@ -0,0 +1,70 @@ +# ADR-001: Fine-Grained Seven-Project Clean Architecture Structure + +- **Date:** 2026-04-26 +- **Status:** Superseded by [ADR-002](ADR-002.md) + +## Context + +The solution began as a Clean Architecture REST API starter for .NET. The +prevailing convention in the .NET ecosystem for expressing Clean Architecture is +to make each architectural layer its own compiled project. The appeal is +enforcement: the dependency rule (dependencies point inward, business logic stays +free of infrastructure) is guaranteed by the compiler, because a project cannot +reference something it has no project reference to. A layer with no reference to +the persistence layer physically cannot compile a call into it. + +Wanting the strongest possible enforcement and the cleanest separation of +concerns, the initial design maximized this split into seven projects: + +1. **Domain** — entities and core types; depends on nothing. +2. **Application** — use cases, service abstractions (e.g. repository + interfaces), DTOs, and validators; the seam between domain and infrastructure. +3. **Infrastructure** — EF Core, identity, and the implementations of the + application's abstractions. +4. **Api** — the HTTP host, endpoints, and composition root. +5. **Configuration** — settings/options classes and their registration; a neutral + leaf shared by both web and infrastructure concerns without coupling them. +6. **AspNetCore** — reusable web/runtime defaults (telemetry, problem details, + API versioning, authentication, validation, health checks). +7. **AppHost** — local-development orchestration (database, admin tooling). + +It is worth noting up front that modern orchestration tooling is itself +responsible for some of this count — a portion of the projects exist to satisfy +the local-development host rather than the application's own architecture. + +## Decision + +Adopt the seven-project structure and rely on project references for +compile-time enforcement of the dependency rule. Each layer and each +cross-cutting concern becomes a distinct project with an explicitly declared set +of references. + +## Consequences + +**Positive** + +- The dependency rule is enforced by the compiler — an illegal inward/outward + reference simply does not build. This is the strongest form of enforcement + available. +- Each project carries a single, clearly named responsibility. +- The platform and configuration projects are, in principle, reusable if the + system later grows into multiple services. + +**Negative** + +- High ceremony. Implementing a single feature touches files spread across + several projects, and day-to-day work involves constant hopping between them. +- The project count is disproportionate to a single small service. Much of the + separation — particularly extracting reusable platform code — is speculative: + it pays off only when reuse actually materializes, which for one service it + never does. +- Onboarding friction is real, and the layout invites a common misconception: + that Clean Architecture *is* this particular set of projects. It is not. The + architecture is fundamentally about the **direction of dependencies** and the + protection of business logic from external concerns — not about how many + projects exist. A large multi-project solution is not a prerequisite for good + architecture; it is one implementation of the dependency rule, and a heavy one. + +This tension — strong enforcement bought at the cost of significant ceremony, much +of it speculative — is the motivation for revisiting the structure in +[ADR-002](ADR-002.md). diff --git a/adr/ADR-002.md b/adr/ADR-002.md new file mode 100644 index 0000000..3405394 --- /dev/null +++ b/adr/ADR-002.md @@ -0,0 +1,82 @@ +# ADR-002: Maintain Two Parallel Variants — Layered and Modular + +- **Date:** 2026-06-17 +- **Status:** Superseded by [ADR-003](ADR-003.md) +- **Supersedes:** [ADR-001](ADR-001.md) + +## Context + +The seven-project structure ([ADR-001](ADR-001.md)) delivered strong, compiler- +enforced boundaries but at a cost in ceremony that felt disproportionate for a +single service. Reassessing the design surfaced three insights that reframed the +problem. + +First, what is usually discussed as one decision is really **three independent +decisions**, and conflating them causes most architecture arguments: + +- **Feature organization** — *where do I find the code?* +- **Code reuse** — *is logic consistent across features?* +- **Dependency management** — *what is allowed to depend on what?* + +Clean Architecture is an answer to dependency management. Organizing by feature +(vertical slices) is an answer to feature organization. These are orthogonal and +not opposites; a system can — and generally should — do both. + +Second, compile-time enforcement of the dependency rule **does not require +separate projects**. A namespace-dependency analyzer can inspect dependencies at +build time and fail the build when code crosses a forbidden boundary, achieving +inside a single project what project references achieve across many. + +Third, there was no single right answer for the audience. Some teams value hard, +physical boundaries and accept the ceremony; others want feature-centric +organization with minimal ceremony. Both are legitimate, and forcing one on +everyone would be wrong. + +A note on duplication informed the modular design: organizing by feature does +**not** mean duplicating everything into every feature. Shared domain concepts and +shared infrastructure (for example, a single database context and its +configuration) should be shared, not copied. Only slice-level glue — request and +response shapes, validators, and the orchestration of a single operation — is +reasonably duplicated, because coupling unrelated features through a shared +abstraction is often worse than a little repetition. + +## Decision + +Keep both structures, expressed as **two self-contained templates in one +repository**: + +- **Layered** — the multi-project structure, with boundaries enforced by project + references. For teams that want hard, physical separation. +- **Modular** — a single application project organized by feature folders, with + boundaries enforced by a namespace-dependency analyzer whose violations are + elevated to build errors. Reuses the platform and configuration projects. + +Concerns genuinely shared by both — the database schema (kept as a single source +of truth and synced into each variant), documentation, and continuous +integration — are hoisted to the repository root so the two variants cannot drift +on the parts that must agree. + +## Consequences + +**Positive** + +- Users pick the structure that fits their context without giving up enforced + dependency boundaries in either case. +- The pairing demonstrates concretely that Clean Architecture and vertical slices + coexist; they sit on different axes (dependency management vs. feature + organization). +- The duplication guidance is made explicit and consistent across both variants. + +**Negative** + +- Two trees must be maintained, with a real risk of drift. This is mitigated, but + not eliminated, by sharing the schema, docs, and CI at the root. +- Because each variant must be self-contained to function as a standalone + template, some configuration files and platform projects are necessarily + duplicated between them. +- Carrying two architectural philosophies in one repository increases the + cognitive load on maintainers and can dilute the project's message — a reader + must now understand both, and decide between them, before getting started. + +The maintenance overhead and divided focus of running two variants is what drives +the consolidation in [ADR-003](ADR-003.md). diff --git a/adr/ADR-003.md b/adr/ADR-003.md new file mode 100644 index 0000000..02b784d --- /dev/null +++ b/adr/ADR-003.md @@ -0,0 +1,90 @@ +# ADR-003: Consolidate to a Single Modular Solution at the Root (Three Projects) + +- **Date:** 2026-06-20 +- **Status:** Accepted +- **Supersedes:** [ADR-002](ADR-002.md) + +## Context + +Maintaining two parallel variants ([ADR-002](ADR-002.md)) doubled the maintenance +surface and split the project's focus. A reader had to understand both structures +before starting, and every shared change had to be applied carefully in two +places to avoid drift. The cost of running both was no longer justified by the +benefit. + +Re-examining the layered variant project by project showed that only a subset of +its projects were actually load-bearing: + +- The **application ↔ infrastructure** boundary is the one that matters — it is + what delivers dependency inversion, lets business logic be tested without a + database, and keeps persistence swappable. +- The **domain ↔ application** boundary is a *soft* rule. A leak in that + direction is far less harmful than anything reaching into infrastructure, and + many established Clean Architecture layouts keep domain and application together + in a single inner project. +- The **platform, configuration, and orchestration** projects were either + speculative reuse (extracted for a second service that does not exist) or the + unavoidable tax of local-development tooling. + +Crucially, the dependency rule can be enforced **completely within one project** +by a namespace-dependency analyzer that elevates illegal dependencies to build +errors. Separate projects are therefore not necessary to *enforce* the +architecture — they are only one way to do it, and the heaviest one. + +Two further considerations pointed the same way. Number of projects is not an +architectural principle; the architecture is the dependency direction, and that +can be preserved regardless of project count. And **cohesion and locality** — +keeping the code for one capability together — measurably reduce friction, both +for people navigating the codebase and for automated and agent-driven tooling +that benefits from consistency and co-located context. The modular variant had +already proven this end to end: feature-centric organization, boundaries enforced +at build time, and a working template. + +## Decision + +Retire the layered variant. Keep only the modular / vertical-slice solution and +promote it to the repository root. Reduce the structure to **three projects**: + +1. **Api** — the entire application: domain, features (vertical slices), + infrastructure, configuration/options, and composition. Internal boundaries + (domain ✗→ infrastructure, features ✗→ infrastructure, domain ✗→ features) are + enforced by a namespace-dependency analyzer configured to fail the build. +2. **AspNetCoreDefaults** — reusable, application-agnostic web and runtime + defaults. It deliberately holds no knowledge of the application's settings; the + composition root (Api) binds configuration-dependent options such as JWT token + validation. +3. **AppHost** — local-development orchestration. + +The dependency rule is enforced by the analyzer and configuration, not by the +number of projects. + +## Consequences + +**Positive** + +- Dramatically less ceremony. A feature lives in one place; there is no + project-hopping to implement or trace a single operation, which lowers + onboarding cost. +- A single source of truth — no second variant to keep in sync, no drift. +- The dependency guarantees that actually matter are preserved and still enforced + at build time; the architecture is unchanged, only its packaging is simpler. +- Better cohesion and locality, which helps human comprehension and consistent, + tool-assisted change. + +**Negative** + +- The "physically cannot even reference it" guarantee of separate projects is + traded for analyzer-based enforcement. This depends on the analyzer being + present and correctly configured; the risk is mitigated by committing the + configuration and treating its violations as build errors. +- Teams that specifically prefer hard, project-level boundaries no longer have a + ready-made option. This is an accepted trade-off: the analyzer provides + equivalent enforcement of the same rule. +- Folding configuration into the Api project slightly broadens its surface, but + keeps the defaults project genuinely reusable and free of app-specific types. +- References to the retired layered variant in documentation and tooling must be + updated. + +Simplicity and cohesion won because the separation that was removed was either +speculative or ceremonial, while everything that genuinely protects the +architecture — the inward dependency direction — is retained and still enforced. diff --git a/adr/README.md b/adr/README.md new file mode 100644 index 0000000..333a0c6 --- /dev/null +++ b/adr/README.md @@ -0,0 +1,20 @@ +# Architecture Decision Records + +This folder records the significant architectural decisions for this solution and +how they evolved. Each record follows a standard format (Title, Date, Status, +Context, Decision, Consequences) and is immutable once accepted — superseding +decisions are captured in new records rather than by editing old ones. + +| ADR | Title | Status | +| --- | --- | --- | +| [ADR-001](ADR-001.md) | Fine-grained seven-project Clean Architecture structure | Superseded by ADR-002 | +| [ADR-002](ADR-002.md) | Maintain two parallel variants — layered and modular | Superseded by ADR-003 | +| [ADR-003](ADR-003.md) | Consolidate to a single modular solution at the root (three projects) | Accepted | + +The progression in one line: a fine-grained seven-project split → two parallel +variants (layered + modular) → a single modular solution of three projects, where +boundaries are enforced by a namespace-dependency analyzer rather than by project +count. + +Background reading on the principles behind these decisions lives in +[`../docs/architecture`](../docs/architecture/clean-architecture-and-vertical-slices.md). diff --git a/docs/architecture/clean-architecture-and-vertical-slices.md b/docs/architecture/clean-architecture-and-vertical-slices.md new file mode 100644 index 0000000..e045bd2 --- /dev/null +++ b/docs/architecture/clean-architecture-and-vertical-slices.md @@ -0,0 +1,142 @@ +# Clean Architecture, Vertical Slices, and Duplication + +This document records the architectural reasoning behind CleanApiStarter — *why* +the structure is the way it is, not just *what* it is. The history of how the +structure evolved is captured in the [architecture decision records](../../adr/). + +## The core idea: three independent decisions + +The single most useful reframing is that "architecture" is not one choice. It is +at least three *separate, orthogonal* decisions, and most online arguments +conflate them: + +1. **Feature organization** — *"Where do I find the code?"* + Do related files for one capability live together, or are they scattered + across `Controllers/`, `Services/`, `Repositories/` folders? + +2. **Code reuse** — *"Is logic consistent across features?"* + When two features need the same rule or the same infrastructure, do they + share one implementation or each carry their own? + +3. **Dependency management** — *"What is allowed to depend on what?"* + Which directions of dependency are legal, and how is that enforced? + +Vertical Slice Architecture (VSA) is primarily an answer to **#1**. +Clean Architecture is primarily an answer to **#3**. They are not opposites and +they are not mutually exclusive — they answer different questions. You can, and +generally should, do both. + +## What Clean Architecture actually is + +Clean Architecture is **about dependency direction**, not about a project count +or a specific 2018-era folder structure. Its essential rules: + +- Protect business logic from external concerns (UI, DB, frameworks). +- Isolate infrastructure behind abstractions. +- Make dependencies point *inward* — outer layers depend on inner layers, never + the reverse. + +That is the whole of it. "Six projects" is one *implementation* of that rule, not +the rule itself. A single project can be a Clean Architecture as long as the +dependency direction is preserved and enforced. + +## What Vertical Slices actually are (and the duplication myth) + +A "vertical slice" groups everything needed for one operation together — for a +"create project" endpoint, that's the route, request/response DTOs, validation, +and the handler logic in one place — so adding or changing a feature means +working in one location instead of scrolling through parallel `Controllers/`, +`Services/`, `Models/` folders. + +The common misconception is that VSA means **"duplicate everything into every +feature folder."** It does not, and almost no real-world VSA codebase does this. +Even popular VSA templates centralize the domain model, persistence, and +infrastructure in a shared/common area; only the *endpoint-layer* types live in +the slice. In practice, "vertical slices" usually means *the UI/endpoint layer is +organized by feature* — not that business rules and persistence are copied per +feature. + +> Not everything belongs duplicated into every feature folder forever. Some +> abstractions are useful. Sharing domain concepts instead of duplicating them is +> helpful. Reusing consistent infrastructure (e.g. one DbContext) makes sense. + +## Duplication: the practical rule + +"Is duplication okay?" is a **code-reuse** decision (#2) and the answer is +*"yes for some things, no for others."* The line that has served us well: + +| Keep per-slice (duplication is fine, often preferred) | Share (duplication is a bug) | +| --- | --- | +| Request / response DTOs | Domain entities and their invariants | +| Validators | The DbContext and EF configuration | +| Handler / orchestration logic | Cross-cutting infrastructure (auth, logging) | +| Query shapes and projections | Genuinely shared business rules | + +Why per-slice duplication is *good*: forcing two features through one shared +service couples them, so a change for feature A can break feature B (the "ripple +effect"). Two near-identical handlers that evolve independently are cheaper than +one shared abstraction fighting both callers. This is the classic *"prefer +duplication over the wrong abstraction"* rule — but scoped to slice-level glue. + +Why domain/infrastructure duplication is *bad*: if a business rule (e.g. "a +task's `CompletedAt` is set when its status becomes `Done`") is copied into +multiple handlers, the copies drift out of sync. That rule belongs on the entity, +defined once. + +## How the boundaries are enforced + +Dependency rules are only real if something *fails the build* when they are +broken. CleanApiStarter enforces them with **[NsDepCop](https://github.com/realvizu/NsDepCop)**, +a Roslyn analyzer that polices *namespace* dependencies at compile time. A +declarative `config.nsdepcop` lists the illegal directions, and +`NSDEPCOP01` in the csproj turns a violation +into a build-breaking error — recreating inside a single project the guarantee +that separate projects would otherwise give through project references. The +ruleset is default-allow, then blacklists the inward-violating directions: + +```xml + + +``` + +## Two ways to enforce the same rule + +The dependency rule can be enforced two ways, and both are legitimate Clean +Architectures — they differ only in feature organization and enforcement mechanism, +not in whether they respect dependency direction: + +| | Multi-project | Single project | +| --- | --- | --- | +| Shape | A project per layer (Api, Application, Domain, Infrastructure, …) | One application project organized by feature folders | +| Feature organization | Feature folders inside layered projects | Vertical slices in `Features/` | +| Enforcement | Project references (compiler) | Namespace analyzer, build-breaking | +| Best for | Teams who want hard, physical boundaries | Smaller apps, less ceremony, feature-centric work | + +CleanApiStarter uses the single-project approach; see the +[decision records](../../adr/) for how it arrived there. + +## Where this is heading: the modular monolith + +The natural destination of "fewer projects + feature-centric + enforced +boundaries" is the **modular monolith**: top-level folders organized by *business +capability* (not by technical layer), each module owning its own slices, domain, +and persistence, exposing an explicit public contract, with a shared kernel only +for genuinely shared concepts. NsDepCop then enforces both the inward dependency +rule *and* inter-module isolation (e.g. `Modules.Projects.* ✗→ +Modules.Billing.Internal.*`). + +This gives much of the modularity and independence people reach microservices for +— autonomy, encapsulation, clear contracts — without the operational tax of a +distributed system. The single-project layout here is intentionally a stepping +stone in this direction: its `Features/` layout can grow into `Modules/` over time. + +## Summary + +- Clean Architecture = dependency direction. Vertical Slices = feature + organization. Code reuse = a third, separate decision. Don't conflate them. +- Project count is an implementation detail, not an architectural principle. +- Duplicate slice-level glue freely; never duplicate domain rules or + infrastructure. +- Enforce boundaries with something that breaks the build — a namespace analyzer + (used here) or project references. +- The modular monolith is the long-term target for serious business apps. diff --git a/scripts/install-template.sh b/scripts/install-template.sh index e41dcbe..ecfed48 100755 --- a/scripts/install-template.sh +++ b/scripts/install-template.sh @@ -2,12 +2,12 @@ set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -name="CleanApiStarter.Template.0.0.0.nupkg" -pkg="$root/artifacts/$name" +package="CleanApiStarter.Template" +pkg="$root/artifacts/$package.0.0.0.nupkg" cd "$root" -dotnet new uninstall CleanApiStarter.Template 2>/dev/null || true +dotnet new uninstall "$package" 2>/dev/null || true dotnet pack "CleanApiStarter.Template.csproj" --configuration Release --output "artifacts" dotnet new install "$pkg" --force rm -f "$pkg" diff --git a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj index 49fc06b..269ddb0 100644 --- a/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj +++ b/src/CleanApiStarter.Api/CleanApiStarter.Api.csproj @@ -4,12 +4,25 @@ CleanApiStarter.Api CleanApiStarter.Api clean-api-starter + NSDEPCOP01 - - - + + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + diff --git a/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs b/src/CleanApiStarter.Api/Common/Interfaces/IUser.cs similarity index 58% rename from src/CleanApiStarter.Application/Common/Interfaces/IUser.cs rename to src/CleanApiStarter.Api/Common/Interfaces/IUser.cs index 5064363..9747b92 100644 --- a/src/CleanApiStarter.Application/Common/Interfaces/IUser.cs +++ b/src/CleanApiStarter.Api/Common/Interfaces/IUser.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Common.Interfaces; +namespace CleanApiStarter.Api.Common.Interfaces; public interface IUser { diff --git a/src/CleanApiStarter.Api/Common/Interfaces/UserExtensions.cs b/src/CleanApiStarter.Api/Common/Interfaces/UserExtensions.cs new file mode 100644 index 0000000..cf0892d --- /dev/null +++ b/src/CleanApiStarter.Api/Common/Interfaces/UserExtensions.cs @@ -0,0 +1,13 @@ +namespace CleanApiStarter.Api.Common.Interfaces; + +public static class UserExtensions +{ + /// + /// Returns the authenticated user's id, or throws if the request is unauthenticated. + /// Endpoints are protected by authorization, so this is a defensive guard. + /// + public static string RequireId(this IUser user) + { + return user.Id ?? throw new UnauthorizedAccessException(); + } +} diff --git a/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs b/src/CleanApiStarter.Api/Common/Models/ArrayResult.cs similarity index 72% rename from src/CleanApiStarter.Application/Common/Models/ArrayResult.cs rename to src/CleanApiStarter.Api/Common/Models/ArrayResult.cs index 3e99c59..e7af6b4 100644 --- a/src/CleanApiStarter.Application/Common/Models/ArrayResult.cs +++ b/src/CleanApiStarter.Api/Common/Models/ArrayResult.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Common.Models; +namespace CleanApiStarter.Api.Common.Models; public sealed class ArrayResult { diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs b/src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs similarity index 69% rename from src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs rename to src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs index ca80f8a..17feb6b 100644 --- a/src/CleanApiStarter.Application/Common/Models/PaginatedQuery.cs +++ b/src/CleanApiStarter.Api/Common/Models/PaginatedQuery.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Common.Models; +namespace CleanApiStarter.Api.Common.Models; public sealed class PaginatedQuery { diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs b/src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs similarity index 84% rename from src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs rename to src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs index 8db6a25..e5516e5 100644 --- a/src/CleanApiStarter.Application/Common/Models/PaginatedQueryValidator.cs +++ b/src/CleanApiStarter.Api/Common/Models/PaginatedQueryValidator.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Common.Models; +namespace CleanApiStarter.Api.Common.Models; public sealed class PaginatedQueryValidator : AbstractValidator { diff --git a/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs b/src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs similarity index 92% rename from src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs rename to src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs index c1259db..d40ede1 100644 --- a/src/CleanApiStarter.Application/Common/Models/PaginatedResult.cs +++ b/src/CleanApiStarter.Api/Common/Models/PaginatedResult.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Common.Models; +namespace CleanApiStarter.Api.Common.Models; public sealed class PaginatedResult { diff --git a/src/CleanApiStarter.Configuration/AppSettings.cs b/src/CleanApiStarter.Api/Configuration/AppSettings.cs similarity index 85% rename from src/CleanApiStarter.Configuration/AppSettings.cs rename to src/CleanApiStarter.Api/Configuration/AppSettings.cs index 19cdf16..cac0758 100644 --- a/src/CleanApiStarter.Configuration/AppSettings.cs +++ b/src/CleanApiStarter.Api/Configuration/AppSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class AppSettings { diff --git a/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs b/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs new file mode 100644 index 0000000..86dad5c --- /dev/null +++ b/src/CleanApiStarter.Api/Configuration/AuthenticationConfiguration.cs @@ -0,0 +1,27 @@ +namespace CleanApiStarter.Api.Configuration; + +public static class AuthenticationConfiguration +{ + public static IServiceCollection AddJwtBearerOptions(this IServiceCollection services) + { + services.AddOptions(JwtBearerDefaults.AuthenticationScheme) + .Configure((options, appSettings) => + { + JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt; + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwtSettings.Issuer, + ValidateAudience = true, + ValidAudience = jwtSettings.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SigningKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1) + }; + }); + + return services; + } +} diff --git a/src/CleanApiStarter.Configuration/AuthenticationSettings.cs b/src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs similarity index 85% rename from src/CleanApiStarter.Configuration/AuthenticationSettings.cs rename to src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs index 24f76fa..19e0227 100644 --- a/src/CleanApiStarter.Configuration/AuthenticationSettings.cs +++ b/src/CleanApiStarter.Api/Configuration/AuthenticationSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class AuthenticationSettings { diff --git a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs b/src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs similarity index 73% rename from src/CleanApiStarter.Configuration/ConnectionStringSettings.cs rename to src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs index 1299016..4f9b846 100644 --- a/src/CleanApiStarter.Configuration/ConnectionStringSettings.cs +++ b/src/CleanApiStarter.Api/Configuration/ConnectionStringSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class ConnectionStringSettings { diff --git a/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs b/src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs similarity index 73% rename from src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs rename to src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs index b6f8aec..a7cbf13 100644 --- a/src/CleanApiStarter.Configuration/GoogleAuthenticationSettings.cs +++ b/src/CleanApiStarter.Api/Configuration/GoogleAuthenticationSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class GoogleAuthenticationSettings { diff --git a/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs b/src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs similarity index 88% rename from src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs rename to src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs index 083c6d8..abc0bc1 100644 --- a/src/CleanApiStarter.Configuration/JwtAuthenticationSettings.cs +++ b/src/CleanApiStarter.Api/Configuration/JwtAuthenticationSettings.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public sealed class JwtAuthenticationSettings { diff --git a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs b/src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs similarity index 91% rename from src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs rename to src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs index 6b767c7..77b0e32 100644 --- a/src/CleanApiStarter.Configuration/OptionsRegistrationExtensions.cs +++ b/src/CleanApiStarter.Api/Configuration/OptionsRegistrationExtensions.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Configuration; +namespace CleanApiStarter.Api.Configuration; public static class OptionsRegistrationExtensions { diff --git a/src/CleanApiStarter.Domain/Entities/Project.cs b/src/CleanApiStarter.Api/Domain/Entities/Project.cs similarity index 89% rename from src/CleanApiStarter.Domain/Entities/Project.cs rename to src/CleanApiStarter.Api/Domain/Entities/Project.cs index 37555ff..e5d562e 100644 --- a/src/CleanApiStarter.Domain/Entities/Project.cs +++ b/src/CleanApiStarter.Api/Domain/Entities/Project.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Domain.Entities; +namespace CleanApiStarter.Api.Domain.Entities; public sealed class Project { diff --git a/src/CleanApiStarter.Domain/Entities/ProjectMember.cs b/src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs similarity index 83% rename from src/CleanApiStarter.Domain/Entities/ProjectMember.cs rename to src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs index c55ef9f..c3ec29e 100644 --- a/src/CleanApiStarter.Domain/Entities/ProjectMember.cs +++ b/src/CleanApiStarter.Api/Domain/Entities/ProjectMember.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Domain.Entities; +namespace CleanApiStarter.Api.Domain.Entities; public sealed class ProjectMember { diff --git a/src/CleanApiStarter.Domain/Entities/ProjectTask.cs b/src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs similarity index 91% rename from src/CleanApiStarter.Domain/Entities/ProjectTask.cs rename to src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs index c12e61c..3fc5ebb 100644 --- a/src/CleanApiStarter.Domain/Entities/ProjectTask.cs +++ b/src/CleanApiStarter.Api/Domain/Entities/ProjectTask.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Domain.Entities; +namespace CleanApiStarter.Api.Domain.Entities; public sealed class ProjectTask { diff --git a/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs b/src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs similarity index 63% rename from src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs rename to src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs index f553dc5..b59bcef 100644 --- a/src/CleanApiStarter.Domain/Entities/ProjectTaskStatus.cs +++ b/src/CleanApiStarter.Api/Domain/Entities/ProjectTaskStatus.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Domain.Entities; +namespace CleanApiStarter.Api.Domain.Entities; public enum ProjectTaskStatus { diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs b/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs deleted file mode 100644 index dc064cb..0000000 --- a/src/CleanApiStarter.Api/Endpoints/V1/Auth.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace CleanApiStarter.Api.Endpoints.V1; - -public sealed class Auth : IEndpointGroup -{ - public static int MajorVersion => 1; - - public static string RoutePrefix => "/api/auth"; - - public static void Map(RouteGroupBuilder groupBuilder) - { - groupBuilder.MapPost("/google", SignInWithGoogle) - .AllowAnonymous() - .WithName("SignInWithGoogleV1"); - - groupBuilder.MapGet("/me", GetCurrentUser) - .RequireAuthorization() - .WithName("GetCurrentUserV1"); - } - - private static async Task SignInWithGoogle( - GoogleSignInDto signInDto, - IAuthService authService, - CancellationToken cancellationToken) - { - AuthTokenDto token = await authService.SignInWithGoogleAsync(signInDto, cancellationToken); - - return Results.Ok(token); - } - - private static async Task GetCurrentUser( - ClaimsPrincipal principal, - IAuthService authService, - CancellationToken cancellationToken) - { - CurrentUserDto currentUser = await authService.GetCurrentUserAsync(principal, cancellationToken); - - return Results.Ok(currentUser); - } -} diff --git a/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs b/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs deleted file mode 100644 index c55c4a9..0000000 --- a/src/CleanApiStarter.Api/Endpoints/V1/Projects.cs +++ /dev/null @@ -1,176 +0,0 @@ -namespace CleanApiStarter.Api.Endpoints.V1; - -public sealed class Projects : IEndpointGroup -{ - public static int MajorVersion => 1; - - public static string RoutePrefix => "/api/projects"; - - public static void Map(RouteGroupBuilder groupBuilder) - { - groupBuilder.RequireAuthorization(); - - groupBuilder.MapPost("/", CreateProject) - .WithName("CreateProjectV1"); - - groupBuilder.MapGet("/", GetProjects) - .WithName("GetProjectsV1"); - - groupBuilder.MapGet("/{id:guid}", GetProject) - .WithName("GetProjectV1"); - - groupBuilder.MapDelete("/{id:guid}", DeleteProject) - .WithName("DeleteProjectV1"); - - groupBuilder.MapPost("/{projectId:guid}/tasks", CreateTask) - .WithName("CreateProjectTaskV1"); - - groupBuilder.MapGet("/{projectId:guid}/tasks", GetTasks) - .WithName("GetProjectTasksV1"); - - groupBuilder.MapGet("/{projectId:guid}/tasks/{taskId:guid}", GetTask) - .WithName("GetProjectTaskV1"); - - groupBuilder.MapPut("/{projectId:guid}/tasks/{taskId:guid}", UpdateTask) - .WithName("UpdateProjectTaskV1"); - - groupBuilder.MapPost("/{projectId:guid}/tasks/{taskId:guid}/complete", CompleteTask) - .WithName("CompleteProjectTaskV1"); - - groupBuilder.MapDelete("/{projectId:guid}/tasks/{taskId:guid}", DeleteTask) - .WithName("DeleteProjectTaskV1"); - } - - private static async Task CreateProject( - CreateProjectDto projectDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - Guid id = await projectService.CreateProjectAsync(projectDto, cancellationToken); - - return Results.Created($"/api/projects/{id}", id); - } - - private static async Task GetProjects( - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); - - return Results.Ok(projects); - } - - private static async Task GetProject( - Guid id, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectDto? project = await projectService.GetProjectAsync(id, cancellationToken); - - return project == null ? Results.NotFound() : Results.Ok(project); - } - - private static async Task DeleteProject( - Guid id, - IProjectService projectService, - CancellationToken cancellationToken) - { - DeleteProjectResult result = await projectService.DeleteProjectAsync(id, cancellationToken); - - return result switch - { - DeleteProjectResult.Deleted => Results.NoContent(), - DeleteProjectResult.Forbidden => Results.Forbid(), - _ => Results.NotFound() - }; - } - - private static async Task CreateTask( - Guid projectId, - CreateProjectTaskDto taskDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - Guid? taskId = await projectService.CreateTaskAsync(projectId, taskDto, cancellationToken); - - return taskId == null - ? Results.NotFound() - : Results.Created($"/api/projects/{projectId}/tasks/{taskId}", taskId); - } - - private static async Task GetTasks( - Guid projectId, - [FromQuery] ProjectTaskStatus? status, - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult? tasks = await projectService.GetTasksAsync( - projectId, - status, - query, - cancellationToken); - - return tasks == null ? Results.NotFound() : Results.Ok(tasks); - } - - private static async Task GetTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskDto? task = await projectService.GetTaskAsync(projectId, taskId, cancellationToken); - - return task == null ? Results.NotFound() : Results.Ok(task); - } - - private static async Task UpdateTask( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskMutationResult result = await projectService.UpdateTaskAsync( - projectId, - taskId, - taskDto, - cancellationToken); - - return result == ProjectTaskMutationResult.Success - ? Results.NoContent() - : Results.NotFound(); - } - - private static async Task CompleteTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - ProjectTaskMutationResult result = await projectService.CompleteTaskAsync(projectId, taskId, cancellationToken); - - return result switch - { - ProjectTaskMutationResult.Success => Results.NoContent(), - ProjectTaskMutationResult.AlreadyCompleted => Results.Conflict(new - { - detail = "Task is already completed." - }), - _ => Results.NotFound() - }; - } - - private static async Task DeleteTask( - Guid projectId, - Guid taskId, - IProjectService projectService, - CancellationToken cancellationToken) - { - bool deleted = await projectService.DeleteTaskAsync(projectId, taskId, cancellationToken); - - return deleted ? Results.NoContent() : Results.NotFound(); - } -} diff --git a/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs b/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs deleted file mode 100644 index a0736b0..0000000 --- a/src/CleanApiStarter.Api/Endpoints/V2/Projects.cs +++ /dev/null @@ -1,35 +0,0 @@ -namespace CleanApiStarter.Api.Endpoints.V2; - -public sealed class Projects : IEndpointGroup -{ - public static int MajorVersion => 2; - - public static string RoutePrefix => "/api/projects"; - - public static void Map(RouteGroupBuilder groupBuilder) - { - groupBuilder.RequireAuthorization(); - - groupBuilder.MapGet("/", GetProjects) - .WithName("GetProjectsV2"); - } - - private static async Task GetProjects( - [AsParameters] PaginatedQuery query, - IProjectService projectService, - CancellationToken cancellationToken) - { - PaginatedResult projects = await projectService.GetProjectsAsync(query, cancellationToken); - - return Results.Ok(new - { - ApiVersion = "2.0", - projects.Items, - projects.Limit, - projects.Offset, - projects.TotalCount, - projects.HasPreviousPage, - projects.HasNextPage - }); - } -} diff --git a/src/CleanApiStarter.Api/Features/Auth/Auth.cs b/src/CleanApiStarter.Api/Features/Auth/Auth.cs new file mode 100644 index 0000000..e141ccb --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Auth/Auth.cs @@ -0,0 +1,14 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public sealed class Auth : IEndpointGroup +{ + public static int MajorVersion => 1; + + public static string RoutePrefix => "/api/auth"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + SignInWithGoogle.Map(groupBuilder); + GetCurrentUser.Map(groupBuilder); + } +} diff --git a/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs b/src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs similarity index 74% rename from src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs rename to src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs index d115f40..182836b 100644 --- a/src/CleanApiStarter.Application/Features/Auth/AuthTokenDto.cs +++ b/src/CleanApiStarter.Api/Features/Auth/AuthTokenDto.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Features.Auth; +namespace CleanApiStarter.Api.Features.Auth; public sealed class AuthTokenDto { diff --git a/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs b/src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs similarity index 82% rename from src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs rename to src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs index f64b9d1..b6f0cd9 100644 --- a/src/CleanApiStarter.Application/Features/Auth/CurrentUserDto.cs +++ b/src/CleanApiStarter.Api/Features/Auth/CurrentUserDto.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Features.Auth; +namespace CleanApiStarter.Api.Features.Auth; public sealed class CurrentUserDto { diff --git a/src/CleanApiStarter.Api/Features/Auth/GetCurrentUser.cs b/src/CleanApiStarter.Api/Features/Auth/GetCurrentUser.cs new file mode 100644 index 0000000..9e791f7 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Auth/GetCurrentUser.cs @@ -0,0 +1,21 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public static class GetCurrentUser +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/me", Handle) + .RequireAuthorization() + .WithName("GetCurrentUserV1"); + } + + private static async Task Handle( + ClaimsPrincipal principal, + IAuthService authService, + CancellationToken cancellationToken) + { + CurrentUserDto currentUser = await authService.GetCurrentUserAsync(principal, cancellationToken); + + return TypedResults.Ok(currentUser); + } +} diff --git a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs b/src/CleanApiStarter.Api/Features/Auth/GoogleLoginPage.cs similarity index 99% rename from src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs rename to src/CleanApiStarter.Api/Features/Auth/GoogleLoginPage.cs index 516c2a1..61d4a4b 100644 --- a/src/CleanApiStarter.Api/Endpoints/GoogleLoginPage.cs +++ b/src/CleanApiStarter.Api/Features/Auth/GoogleLoginPage.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.Endpoints; +namespace CleanApiStarter.Api.Features.Auth; public static class GoogleLoginPage { diff --git a/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs b/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs new file mode 100644 index 0000000..eb9e328 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Auth/IAuthService.cs @@ -0,0 +1,8 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public interface IAuthService +{ + Task SignInWithGoogleAsync(string idToken, CancellationToken cancellationToken); + + Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken); +} diff --git a/src/CleanApiStarter.Api/Features/Auth/SignInWithGoogle.cs b/src/CleanApiStarter.Api/Features/Auth/SignInWithGoogle.cs new file mode 100644 index 0000000..a32e68c --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Auth/SignInWithGoogle.cs @@ -0,0 +1,32 @@ +namespace CleanApiStarter.Api.Features.Auth; + +public static class SignInWithGoogle +{ + public sealed record Request(string IdToken); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.IdToken) + .NotEmpty(); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/google", Handle) + .AllowAnonymous() + .WithName("SignInWithGoogleV1"); + } + + private static async Task Handle( + Request request, + IAuthService authService, + CancellationToken cancellationToken) + { + AuthTokenDto token = await authService.SignInWithGoogleAsync(request.IdToken, cancellationToken); + + return TypedResults.Ok(token); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/CreateProject.cs b/src/CleanApiStarter.Api/Features/Projects/CreateProject.cs new file mode 100644 index 0000000..ec27e08 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/CreateProject.cs @@ -0,0 +1,57 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class CreateProject +{ + public sealed record Request(string Name, string Description); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Name) + .NotEmpty() + .MaximumLength(100); + + RuleFor(request => request.Description) + .MaximumLength(2_000); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/", Handle) + .WithName("CreateProjectV1"); + } + + private static async Task Handle( + Request request, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + DateTime now = DateTime.UtcNow; + Guid projectId = Guid.NewGuid(); + Project project = new() + { + Id = projectId, + Name = request.Name, + Description = request.Description, + OwnerUserId = userId, + CreatedAt = now, + Members = + [ + new ProjectMember + { + ProjectId = projectId, + UserId = userId, + CreatedAt = now + } + ] + }; + + Guid createdProjectId = await projectRepository.AddProjectAsync(project, cancellationToken); + + return TypedResults.Created($"/api/projects/{createdProjectId}", createdProjectId); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/DeleteProject.cs b/src/CleanApiStarter.Api/Features/Projects/DeleteProject.cs new file mode 100644 index 0000000..d141700 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/DeleteProject.cs @@ -0,0 +1,33 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class DeleteProject +{ + public static void Map(RouteGroupBuilder group) + { + group.MapDelete("/{id:guid}", Handle) + .WithName("DeleteProjectV1"); + } + + private static async Task Handle( + Guid id, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(id, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + if (!await projectRepository.IsProjectOwnerAsync(id, userId, cancellationToken)) + { + return TypedResults.Forbid(); + } + + bool deleted = await projectRepository.DeleteProjectAsync(id, cancellationToken); + + return deleted ? TypedResults.NoContent() : TypedResults.NotFound(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/GetProject.cs b/src/CleanApiStarter.Api/Features/Projects/GetProject.cs new file mode 100644 index 0000000..563a00c --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/GetProject.cs @@ -0,0 +1,23 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class GetProject +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/{id:guid}", Handle) + .WithName("GetProjectV1"); + } + + private static async Task Handle( + Guid id, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + Project? project = await projectRepository.GetProjectAsync(id, currentUser.RequireId(), cancellationToken); + + return project is null + ? TypedResults.NotFound() + : TypedResults.Ok(ProjectDto.From(project)); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/GetProjects.cs b/src/CleanApiStarter.Api/Features/Projects/GetProjects.cs new file mode 100644 index 0000000..8bbdc05 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/GetProjects.cs @@ -0,0 +1,24 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public static class GetProjects +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/", Handle) + .WithName("GetProjectsV1"); + } + + private static async Task Handle( + [AsParameters] PaginatedQuery query, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + PaginatedResult projects = await projectRepository.GetProjectsAsync( + currentUser.RequireId(), + query, + cancellationToken); + + return TypedResults.Ok(projects.Map(ProjectDto.From)); + } +} diff --git a/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs b/src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs similarity index 96% rename from src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs rename to src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs index 8f2f054..44478fe 100644 --- a/src/CleanApiStarter.Application/Features/Projects/IProjectRepository.cs +++ b/src/CleanApiStarter.Api/Features/Projects/IProjectRepository.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Application.Features.Projects; +namespace CleanApiStarter.Api.Features.Projects; public interface IProjectRepository { diff --git a/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs b/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs new file mode 100644 index 0000000..40f617f --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/ProjectDto.cs @@ -0,0 +1,26 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public sealed class ProjectDto +{ + public required Guid Id { get; init; } + + public required string Name { get; init; } + + public required string Description { get; init; } + + public required string OwnerUserId { get; init; } + + public required DateTime CreatedAt { get; init; } + + public static ProjectDto From(Project project) + { + return new ProjectDto + { + Id = project.Id, + Name = project.Name, + Description = project.Description, + OwnerUserId = project.OwnerUserId, + CreatedAt = project.CreatedAt + }; + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Projects.cs b/src/CleanApiStarter.Api/Features/Projects/Projects.cs new file mode 100644 index 0000000..1ac63f5 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Projects.cs @@ -0,0 +1,25 @@ +namespace CleanApiStarter.Api.Features.Projects; + +public sealed class Projects : IEndpointGroup +{ + public static int MajorVersion => 1; + + public static string RoutePrefix => "/api/projects"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + CreateProject.Map(groupBuilder); + GetProjects.Map(groupBuilder); + GetProject.Map(groupBuilder); + DeleteProject.Map(groupBuilder); + + CreateTask.Map(groupBuilder); + GetTasks.Map(groupBuilder); + GetTask.Map(groupBuilder); + UpdateTask.Map(groupBuilder); + CompleteTask.Map(groupBuilder); + DeleteTask.Map(groupBuilder); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/CompleteTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/CompleteTask.cs new file mode 100644 index 0000000..909cd93 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/CompleteTask.cs @@ -0,0 +1,41 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class CompleteTask +{ + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/{projectId:guid}/tasks/{taskId:guid}/complete", Handle) + .WithName("CompleteProjectTaskV1"); + } + + public static async Task Handle( + Guid projectId, + Guid taskId, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + ProjectTask? task = await projectRepository.GetTaskForUpdateAsync( + projectId, + taskId, + currentUser.RequireId(), + cancellationToken); + + if (task is null) + { + return TypedResults.NotFound(); + } + + if (task.Status == ProjectTaskStatus.Done) + { + return TypedResults.Conflict("Task is already completed."); + } + + task.Status = ProjectTaskStatus.Done; + task.CompletedAt = DateTime.UtcNow; + + await projectRepository.SaveChangesAsync(cancellationToken); + + return TypedResults.NoContent(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateTask.cs new file mode 100644 index 0000000..aeb9cda --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/CreateTask.cs @@ -0,0 +1,60 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class CreateTask +{ + public sealed record Request(string Title, string Description, DateTime? DueDate); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Title) + .NotEmpty() + .MaximumLength(150); + + RuleFor(request => request.Description) + .MaximumLength(4_000); + + RuleFor(request => request.DueDate) + .Must(dueDate => !dueDate.HasValue || dueDate.Value > DateTime.UtcNow) + .When(request => request.DueDate.HasValue) + .WithMessage("Due date must be in the future."); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPost("/{projectId:guid}/tasks", Handle) + .WithName("CreateProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Request request, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + ProjectTask task = new() + { + Id = Guid.NewGuid(), + ProjectId = projectId, + Title = request.Title, + Description = request.Description, + Status = ProjectTaskStatus.Todo, + DueDate = request.DueDate, + CreatedAt = DateTime.UtcNow + }; + + Guid taskId = await projectRepository.AddTaskAsync(task, cancellationToken); + + return TypedResults.Created($"/api/projects/{projectId}/tasks/{taskId}", taskId); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/DeleteTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/DeleteTask.cs new file mode 100644 index 0000000..2d88ec0 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/DeleteTask.cs @@ -0,0 +1,29 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class DeleteTask +{ + public static void Map(RouteGroupBuilder group) + { + group.MapDelete("/{projectId:guid}/tasks/{taskId:guid}", Handle) + .WithName("DeleteProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Guid taskId, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + bool deleted = await projectRepository.DeleteTaskAsync(projectId, taskId, cancellationToken); + + return deleted ? TypedResults.NoContent() : TypedResults.NotFound(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTask.cs new file mode 100644 index 0000000..a986de3 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTask.cs @@ -0,0 +1,28 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class GetTask +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/{projectId:guid}/tasks/{taskId:guid}", Handle) + .WithName("GetProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Guid taskId, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + ProjectTask? task = await projectRepository.GetTaskAsync( + projectId, + taskId, + currentUser.RequireId(), + cancellationToken); + + return task is null + ? TypedResults.NotFound() + : TypedResults.Ok(ProjectTaskDto.From(task)); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTasks.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTasks.cs new file mode 100644 index 0000000..1c49ac7 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/GetTasks.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class GetTasks +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/{projectId:guid}/tasks", Handle) + .WithName("GetProjectTasksV1"); + } + + private static async Task Handle( + Guid projectId, + [FromQuery] ProjectTaskStatus? status, + [AsParameters] PaginatedQuery query, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + string userId = currentUser.RequireId(); + + if (!await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken)) + { + return TypedResults.NotFound(); + } + + PaginatedResult tasks = await projectRepository.GetTasksAsync( + projectId, + userId, + status, + query, + cancellationToken); + + return TypedResults.Ok(tasks.Map(ProjectTaskDto.From)); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs new file mode 100644 index 0000000..1c10d90 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/ProjectTaskDto.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public sealed class ProjectTaskDto +{ + public required Guid Id { get; init; } + + public required Guid ProjectId { get; init; } + + public required string Title { get; init; } + + public required string Description { get; init; } + + public required ProjectTaskStatus Status { get; init; } + + public DateTime? DueDate { get; init; } + + public required DateTime CreatedAt { get; init; } + + public DateTime? CompletedAt { get; init; } + + public static ProjectTaskDto From(ProjectTask task) + { + return new ProjectTaskDto + { + Id = task.Id, + ProjectId = task.ProjectId, + Title = task.Title, + Description = task.Description, + Status = task.Status, + DueDate = task.DueDate, + CreatedAt = task.CreatedAt, + CompletedAt = task.CompletedAt + }; + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateTask.cs b/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateTask.cs new file mode 100644 index 0000000..df42df1 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/Tasks/UpdateTask.cs @@ -0,0 +1,67 @@ +namespace CleanApiStarter.Api.Features.Projects.Tasks; + +public static class UpdateTask +{ + public sealed record Request(string Title, string Description, ProjectTaskStatus Status, DateTime? DueDate); + + public sealed class Validator : AbstractValidator + { + public Validator() + { + RuleFor(request => request.Title) + .NotEmpty() + .MaximumLength(150); + + RuleFor(request => request.Description) + .MaximumLength(4_000); + + RuleFor(request => request.Status) + .IsInEnum(); + + RuleFor(request => request.DueDate) + .Must((request, dueDate) => !dueDate.HasValue + || request.Status == ProjectTaskStatus.Done + || dueDate.Value > DateTime.UtcNow) + .When(request => request.DueDate.HasValue && request.Status != ProjectTaskStatus.Done) + .WithMessage("Due date must be in the future."); + } + } + + public static void Map(RouteGroupBuilder group) + { + group.MapPut("/{projectId:guid}/tasks/{taskId:guid}", Handle) + .WithName("UpdateProjectTaskV1"); + } + + private static async Task Handle( + Guid projectId, + Guid taskId, + Request request, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + ProjectTask? task = await projectRepository.GetTaskForUpdateAsync( + projectId, + taskId, + currentUser.RequireId(), + cancellationToken); + + if (task is null) + { + return TypedResults.NotFound(); + } + + task.Title = request.Title; + task.Description = request.Description; + task.DueDate = request.DueDate; + task.Status = request.Status; + task.CompletedAt = request.Status == ProjectTaskStatus.Done + ? task.CompletedAt ?? DateTime.UtcNow + : null; + + await projectRepository.SaveChangesAsync(cancellationToken); + + return TypedResults.NoContent(); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/V2/GetProjects.cs b/src/CleanApiStarter.Api/Features/Projects/V2/GetProjects.cs new file mode 100644 index 0000000..7728936 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/V2/GetProjects.cs @@ -0,0 +1,35 @@ +namespace CleanApiStarter.Api.Features.Projects.V2; + +public static class GetProjects +{ + public static void Map(RouteGroupBuilder group) + { + group.MapGet("/", Handle) + .WithName("GetProjectsV2"); + } + + private static async Task Handle( + [AsParameters] PaginatedQuery query, + IProjectRepository projectRepository, + IUser currentUser, + CancellationToken cancellationToken) + { + PaginatedResult projects = await projectRepository.GetProjectsAsync( + currentUser.RequireId(), + query, + cancellationToken); + + PaginatedResult page = projects.Map(ProjectDto.From); + + return TypedResults.Ok(new + { + ApiVersion = "2.0", + page.Items, + page.Limit, + page.Offset, + page.TotalCount, + page.HasPreviousPage, + page.HasNextPage + }); + } +} diff --git a/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs b/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs new file mode 100644 index 0000000..e751908 --- /dev/null +++ b/src/CleanApiStarter.Api/Features/Projects/V2/Projects.cs @@ -0,0 +1,15 @@ +namespace CleanApiStarter.Api.Features.Projects.V2; + +public sealed class Projects : IEndpointGroup +{ + public static int MajorVersion => 2; + + public static string RoutePrefix => "/api/projects"; + + public static void Map(RouteGroupBuilder groupBuilder) + { + groupBuilder.RequireAuthorization(); + + GetProjects.Map(groupBuilder); + } +} diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs index 58be6da..d32fc96 100644 --- a/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -1,21 +1,41 @@ // Global using directives +global using System.ComponentModel.DataAnnotations; +global using System.IdentityModel.Tokens.Jwt; global using System.Reflection; global using System.Security.Claims; +global using System.Text; global using System.Text.Encodings.Web; -global using CleanApiStarter.Api.Endpoints; +global using CleanApiStarter.Api; +global using CleanApiStarter.Api.Common.Interfaces; +global using CleanApiStarter.Api.Common.Models; +global using CleanApiStarter.Api.Configuration; +global using CleanApiStarter.Api.Domain.Entities; +global using CleanApiStarter.Api.Features.Auth; +global using CleanApiStarter.Api.Features.Projects; +global using CleanApiStarter.Api.Features.Projects.Tasks; +global using CleanApiStarter.Api.Infrastructure; +global using CleanApiStarter.Api.Infrastructure.Identity; +global using CleanApiStarter.Api.Infrastructure.Persistence; +global using CleanApiStarter.Api.Infrastructure.Repositories; global using CleanApiStarter.Api.Services; -global using CleanApiStarter.Application; -global using CleanApiStarter.Application.Common.Interfaces; -global using CleanApiStarter.Application.Common.Models; -global using CleanApiStarter.Application.Features.Auth; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.AspNetCore; -global using CleanApiStarter.Configuration; -global using CleanApiStarter.Domain.Entities; -global using CleanApiStarter.Infrastructure; +global using CleanApiStarter.AspNetCoreDefaults; +global using FluentValidation; + +global using Google.Apis.Auth; + +global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Identity; +global using Microsoft.AspNetCore.Identity.EntityFrameworkCore; global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Routing; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.EntityFrameworkCore.Metadata.Builders; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using Microsoft.Extensions.Options; +global using Microsoft.IdentityModel.Tokens; diff --git a/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs b/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs new file mode 100644 index 0000000..ac813bf --- /dev/null +++ b/src/CleanApiStarter.Api/Infrastructure/Identity/ApplicationUser.cs @@ -0,0 +1,3 @@ +namespace CleanApiStarter.Api.Infrastructure.Identity; + +public sealed class ApplicationUser : IdentityUser; diff --git a/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs b/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs similarity index 96% rename from src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs rename to src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs index 367a101..9c15b69 100644 --- a/src/CleanApiStarter.Infrastructure/Identity/GoogleAuthService.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Identity/GoogleAuthService.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Identity; +namespace CleanApiStarter.Api.Infrastructure.Identity; public sealed class GoogleAuthService( AppSettings appSettings, @@ -8,9 +8,9 @@ public sealed class GoogleAuthService( private const string GoogleLoginProvider = "Google"; private const string DefaultRole = "User"; - public async Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken) + public async Task SignInWithGoogleAsync(string idToken, CancellationToken cancellationToken) { - GoogleJsonWebSignature.Payload payload = await ValidateGoogleTokenAsync(signInDto.IdToken); + GoogleJsonWebSignature.Payload payload = await ValidateGoogleTokenAsync(idToken); ApplicationUser user = await FindOrCreateUserAsync(payload, cancellationToken); IList roles = await userManager.GetRolesAsync(user); DateTimeOffset expiresAt = DateTimeOffset.UtcNow.AddMinutes(appSettings.Authentication.Jwt.ExpirationMinutes); diff --git a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs b/src/CleanApiStarter.Api/Infrastructure/InfrastructureExtensions.cs similarity index 88% rename from src/CleanApiStarter.Infrastructure/DependencyInjection.cs rename to src/CleanApiStarter.Api/Infrastructure/InfrastructureExtensions.cs index 12dcd54..df3c8f9 100644 --- a/src/CleanApiStarter.Infrastructure/DependencyInjection.cs +++ b/src/CleanApiStarter.Api/Infrastructure/InfrastructureExtensions.cs @@ -1,6 +1,6 @@ -namespace CleanApiStarter.Infrastructure; +namespace CleanApiStarter.Api.Infrastructure; -public static class DependencyInjection +public static class InfrastructureExtensions { public static IServiceCollection AddInfrastructure(this IServiceCollection services) { diff --git a/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs similarity index 90% rename from src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs index d0e5c7e..ad3b1ca 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/ApplicationDbContext.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Persistence/ApplicationDbContext.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Persistence; +namespace CleanApiStarter.Api.Infrastructure.Persistence; public sealed class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs similarity index 92% rename from src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs index b919bb6..e913b36 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectConfiguration.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectConfiguration.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Persistence.Configuration; +namespace CleanApiStarter.Api.Infrastructure.Persistence.Configuration; public sealed class ProjectConfiguration : IEntityTypeConfiguration { diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs similarity index 92% rename from src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs index f1a3cf1..aee6cf2 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectMemberConfiguration.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Persistence.Configuration; +namespace CleanApiStarter.Api.Infrastructure.Persistence.Configuration; public sealed class ProjectMemberConfiguration : IEntityTypeConfiguration { diff --git a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs similarity index 94% rename from src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs rename to src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs index aa0dfb5..1571b9c 100644 --- a/src/CleanApiStarter.Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Persistence/Configuration/ProjectTaskConfiguration.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Persistence.Configuration; +namespace CleanApiStarter.Api.Infrastructure.Persistence.Configuration; public sealed class ProjectTaskConfiguration : IEntityTypeConfiguration { diff --git a/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs b/src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs similarity index 98% rename from src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs rename to src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs index 04563a4..c955914 100644 --- a/src/CleanApiStarter.Infrastructure/Repositories/ProjectRepository.cs +++ b/src/CleanApiStarter.Api/Infrastructure/Repositories/ProjectRepository.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Infrastructure.Repositories; +namespace CleanApiStarter.Api.Infrastructure.Repositories; public sealed class ProjectRepository(ApplicationDbContext dbContext) : IProjectRepository { diff --git a/src/CleanApiStarter.Api/Program.cs b/src/CleanApiStarter.Api/Program.cs index 4637d70..c36aa07 100644 --- a/src/CleanApiStarter.Api/Program.cs +++ b/src/CleanApiStarter.Api/Program.cs @@ -3,7 +3,8 @@ builder.AddAspNetCoreDefaults(); builder.Services.AddAppSettings(builder.Configuration); -builder.Services.AddApplication(); +builder.Services.AddJwtBearerOptions(); +builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); builder.Services.AddInfrastructure(); builder.Services.AddScoped(); diff --git a/src/CleanApiStarter.Api/Properties/launchSettings.json b/src/CleanApiStarter.Api/Properties/launchSettings.json index a6d24cd..b182d09 100644 --- a/src/CleanApiStarter.Api/Properties/launchSettings.json +++ b/src/CleanApiStarter.Api/Properties/launchSettings.json @@ -1,15 +1,15 @@ -{ - "$schema": "http://json.schemastore.org/launchsettings.json", - "profiles": { - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "launchUrl": "scalar", - "applicationUrl": "https://localhost:7285", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - } - } -} +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "scalar", + "applicationUrl": "https://localhost:7285", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/CleanApiStarter.Api/appsettings.json b/src/CleanApiStarter.Api/appsettings.json index 4d56694..10f68b8 100644 --- a/src/CleanApiStarter.Api/appsettings.json +++ b/src/CleanApiStarter.Api/appsettings.json @@ -1,9 +1,9 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/CleanApiStarter.Api/config.nsdepcop b/src/CleanApiStarter.Api/config.nsdepcop new file mode 100644 index 0000000..f157c6e --- /dev/null +++ b/src/CleanApiStarter.Api/config.nsdepcop @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + diff --git a/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj b/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj deleted file mode 100644 index 828befe..0000000 --- a/src/CleanApiStarter.Application/CleanApiStarter.Application.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - CleanApiStarter.Application - CleanApiStarter.Application - - - - - - - - - - - - - diff --git a/src/CleanApiStarter.Application/DependencyInjection.cs b/src/CleanApiStarter.Application/DependencyInjection.cs deleted file mode 100644 index 94651fd..0000000 --- a/src/CleanApiStarter.Application/DependencyInjection.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CleanApiStarter.Application; - -public static class DependencyInjection -{ - public static IServiceCollection AddApplication(this IServiceCollection services) - { - services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly); - services.AddScoped(); - - return services; - } -} diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs deleted file mode 100644 index b1ebed4..0000000 --- a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace CleanApiStarter.Application.Features.Auth; - -public sealed class GoogleSignInDto -{ - public required string IdToken { get; init; } -} diff --git a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs b/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs deleted file mode 100644 index 7ef17c3..0000000 --- a/src/CleanApiStarter.Application/Features/Auth/GoogleSignInDtoValidator.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CleanApiStarter.Application.Features.Auth; - -public sealed class GoogleSignInDtoValidator : AbstractValidator -{ - public GoogleSignInDtoValidator() - { - RuleFor(signIn => signIn.IdToken) - .NotEmpty(); - } -} diff --git a/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs b/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs deleted file mode 100644 index 1e1fc6a..0000000 --- a/src/CleanApiStarter.Application/Features/Auth/IAuthService.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace CleanApiStarter.Application.Features.Auth; - -public interface IAuthService -{ - Task SignInWithGoogleAsync(GoogleSignInDto signInDto, CancellationToken cancellationToken); - - Task GetCurrentUserAsync(ClaimsPrincipal principal, CancellationToken cancellationToken); -} diff --git a/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs deleted file mode 100644 index fb524b1..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/CreateProjectDtoValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class CreateProjectDtoValidator : AbstractValidator -{ - public CreateProjectDtoValidator() - { - RuleFor(project => project.Name) - .NotEmpty() - .MaximumLength(100); - - RuleFor(project => project.Description) - .MaximumLength(2_000); - } -} diff --git a/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs deleted file mode 100644 index c06a35f..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/CreateProjectTaskDtoValidator.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class CreateProjectTaskDtoValidator : AbstractValidator -{ - public CreateProjectTaskDtoValidator() - { - RuleFor(task => task.Title) - .NotEmpty() - .MaximumLength(150); - - RuleFor(task => task.Description) - .MaximumLength(4_000); - - RuleFor(task => task.DueDate) - .Must(dueDate => !dueDate.HasValue || dueDate.Value > DateTime.UtcNow) - .When(task => task.DueDate.HasValue) - .WithMessage("Due date must be in the future."); - } -} diff --git a/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs b/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs deleted file mode 100644 index ff0a3c6..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/IProjectService.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public interface IProjectService -{ - Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken); - - Task GetProjectAsync(Guid id, CancellationToken cancellationToken); - - Task> GetProjectsAsync(PaginatedQuery query, CancellationToken cancellationToken); - - Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken); - - Task CreateTaskAsync(Guid projectId, CreateProjectTaskDto taskDto, CancellationToken cancellationToken); - - Task GetTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); - - Task?> GetTasksAsync( - Guid projectId, - ProjectTaskStatus? status, - PaginatedQuery query, - CancellationToken cancellationToken); - - Task UpdateTaskAsync( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - CancellationToken cancellationToken); - - Task CompleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); - - Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken); -} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs deleted file mode 100644 index c8d8356..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectDto.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class ProjectDto -{ - public required Guid Id { get; init; } - - public required string Name { get; init; } - - public required string Description { get; init; } - - public required string OwnerUserId { get; init; } - - public required DateTime CreatedAt { get; init; } -} - -public sealed class CreateProjectDto -{ - public required string Name { get; init; } - - public required string Description { get; init; } -} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs deleted file mode 100644 index 2f4dfae..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectOperationResults.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public enum DeleteProjectResult -{ - Deleted, - NotFound, - Forbidden -} - -public enum ProjectTaskMutationResult -{ - Success, - NotFound, - AlreadyCompleted -} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs deleted file mode 100644 index 9d162bd..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectService.cs +++ /dev/null @@ -1,248 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class ProjectService( - IProjectRepository projectRepository, - IUser currentUser, - ILogger logger) : IProjectService -{ - public async Task CreateProjectAsync(CreateProjectDto projectDto, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - DateTime now = DateTime.UtcNow; - Guid projectId = Guid.NewGuid(); - Project project = new() - { - Id = projectId, - Name = projectDto.Name, - Description = projectDto.Description, - OwnerUserId = userId, - CreatedAt = now, - Members = - [ - new ProjectMember - { - ProjectId = projectId, - UserId = userId, - CreatedAt = now - } - ] - }; - - Guid createdProjectId = await projectRepository.AddProjectAsync(project, cancellationToken); - - logger.LogInformation("Created project {ProjectId} for user {UserId}", createdProjectId, userId); - - return createdProjectId; - } - - public async Task GetProjectAsync(Guid id, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - Project? project = await projectRepository.GetProjectAsync(id, userId, cancellationToken); - - return project == null ? null : MapProject(project); - } - - public async Task> GetProjectsAsync( - PaginatedQuery query, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - PaginatedResult projects = await projectRepository.GetProjectsAsync(userId, query, cancellationToken); - - logger.LogInformation( - "Retrieved {ProjectCount} projects for user {UserId} with limit {Limit} and offset {Offset}", - projects.Items.Count, - userId, - projects.Limit, - projects.Offset); - - return projects.Map(MapProject); - } - - public async Task DeleteProjectAsync(Guid id, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(id, userId, cancellationToken); - - if (!isMember) - { - return DeleteProjectResult.NotFound; - } - - bool isOwner = await projectRepository.IsProjectOwnerAsync(id, userId, cancellationToken); - - if (!isOwner) - { - return DeleteProjectResult.Forbidden; - } - - bool deleted = await projectRepository.DeleteProjectAsync(id, cancellationToken); - - return deleted ? DeleteProjectResult.Deleted : DeleteProjectResult.NotFound; - } - - public async Task CreateTaskAsync( - Guid projectId, - CreateProjectTaskDto taskDto, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return null; - } - - ProjectTask task = new() - { - Id = Guid.NewGuid(), - ProjectId = projectId, - Title = taskDto.Title, - Description = taskDto.Description, - Status = ProjectTaskStatus.Todo, - DueDate = taskDto.DueDate, - CreatedAt = DateTime.UtcNow - }; - - Guid taskId = await projectRepository.AddTaskAsync(task, cancellationToken); - - logger.LogInformation("Created task {TaskId} in project {ProjectId}", taskId, projectId); - - return taskId; - } - - public async Task GetTaskAsync( - Guid projectId, - Guid taskId, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskAsync(projectId, taskId, userId, cancellationToken); - - return task == null ? null : MapTask(task); - } - - public async Task?> GetTasksAsync( - Guid projectId, - ProjectTaskStatus? status, - PaginatedQuery query, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return null; - } - - PaginatedResult tasks = await projectRepository.GetTasksAsync( - projectId, - userId, - status, - query, - cancellationToken); - - return tasks.Map(MapTask); - } - - public async Task UpdateTaskAsync( - Guid projectId, - Guid taskId, - UpdateProjectTaskDto taskDto, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); - - if (task == null) - { - return ProjectTaskMutationResult.NotFound; - } - - task.Title = taskDto.Title; - task.Description = taskDto.Description; - task.DueDate = taskDto.DueDate; - task.Status = taskDto.Status; - task.CompletedAt = taskDto.Status == ProjectTaskStatus.Done - ? task.CompletedAt ?? DateTime.UtcNow - : null; - - await projectRepository.SaveChangesAsync(cancellationToken); - - return ProjectTaskMutationResult.Success; - } - - public async Task CompleteTaskAsync( - Guid projectId, - Guid taskId, - CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - ProjectTask? task = await projectRepository.GetTaskForUpdateAsync(projectId, taskId, userId, cancellationToken); - - if (task == null) - { - return ProjectTaskMutationResult.NotFound; - } - - if (task.Status == ProjectTaskStatus.Done) - { - return ProjectTaskMutationResult.AlreadyCompleted; - } - - task.Status = ProjectTaskStatus.Done; - task.CompletedAt = DateTime.UtcNow; - - await projectRepository.SaveChangesAsync(cancellationToken); - - return ProjectTaskMutationResult.Success; - } - - public async Task DeleteTaskAsync(Guid projectId, Guid taskId, CancellationToken cancellationToken) - { - string userId = GetCurrentUserId(); - bool isMember = await projectRepository.IsProjectMemberAsync(projectId, userId, cancellationToken); - - if (!isMember) - { - return false; - } - - return await projectRepository.DeleteTaskAsync(projectId, taskId, cancellationToken); - } - - private string GetCurrentUserId() - { - return currentUser.Id ?? throw new UnauthorizedAccessException(); - } - - private static ProjectDto MapProject(Project project) - { - return new ProjectDto - { - Id = project.Id, - Name = project.Name, - Description = project.Description, - OwnerUserId = project.OwnerUserId, - CreatedAt = project.CreatedAt - }; - } - - private static ProjectTaskDto MapTask(ProjectTask task) - { - return new ProjectTaskDto - { - Id = task.Id, - ProjectId = task.ProjectId, - Title = task.Title, - Description = task.Description, - Status = task.Status, - DueDate = task.DueDate, - CreatedAt = task.CreatedAt, - CompletedAt = task.CompletedAt - }; - } -} diff --git a/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs b/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs deleted file mode 100644 index 03f71fa..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/ProjectTaskDto.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class ProjectTaskDto -{ - public required Guid Id { get; init; } - - public required Guid ProjectId { get; init; } - - public required string Title { get; init; } - - public required string Description { get; init; } - - public required ProjectTaskStatus Status { get; init; } - - public DateTime? DueDate { get; init; } - - public required DateTime CreatedAt { get; init; } - - public DateTime? CompletedAt { get; init; } -} - -public sealed class CreateProjectTaskDto -{ - public required string Title { get; init; } - - public required string Description { get; init; } - - public DateTime? DueDate { get; init; } -} - -public sealed class UpdateProjectTaskDto -{ - public required string Title { get; init; } - - public required string Description { get; init; } - - public required ProjectTaskStatus Status { get; init; } - - public DateTime? DueDate { get; init; } -} diff --git a/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs b/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs deleted file mode 100644 index 0227274..0000000 --- a/src/CleanApiStarter.Application/Features/Projects/UpdateProjectTaskDtoValidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace CleanApiStarter.Application.Features.Projects; - -public sealed class UpdateProjectTaskDtoValidator : AbstractValidator -{ - public UpdateProjectTaskDtoValidator() - { - RuleFor(task => task.Title) - .NotEmpty() - .MaximumLength(150); - - RuleFor(task => task.Description) - .MaximumLength(4_000); - - RuleFor(task => task.Status) - .IsInEnum(); - - RuleFor(task => task.DueDate) - .Must((task, dueDate) => !dueDate.HasValue - || task.Status == ProjectTaskStatus.Done - || dueDate.Value > DateTime.UtcNow) - .When(task => task.DueDate.HasValue && task.Status != ProjectTaskStatus.Done) - .WithMessage("Due date must be in the future."); - } -} diff --git a/src/CleanApiStarter.Application/GlobalUsings.cs b/src/CleanApiStarter.Application/GlobalUsings.cs deleted file mode 100644 index a886fb3..0000000 --- a/src/CleanApiStarter.Application/GlobalUsings.cs +++ /dev/null @@ -1,11 +0,0 @@ -global using System.Security.Claims; - -global using CleanApiStarter.Application.Common.Interfaces; -global using CleanApiStarter.Application.Common.Models; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.Domain.Entities; - -global using FluentValidation; - -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Logging; diff --git a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs similarity index 77% rename from src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs rename to src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs index 2e7e5bc..12a2a39 100644 --- a/src/CleanApiStarter.AspNetCore/AspNetCoreDefaultServices.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultServices.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Authorization; -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; -public static partial class Extensions +public static partial class AspNetCoreDefaultsExtensions { private static void AddSecurityDefaults(this IHostApplicationBuilder builder) { @@ -46,23 +46,9 @@ private static void AddAuthenticationDefaults(this WebApplicationBuilder builder .Build(); }); - builder.Services.AddOptions(JwtBearerDefaults.AuthenticationScheme) - .Configure((options, appSettings) => - { - JwtAuthenticationSettings jwtSettings = appSettings.Authentication.Jwt; - - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = jwtSettings.Issuer, - ValidateAudience = true, - ValidAudience = jwtSettings.Audience, - ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SigningKey)), - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1) - }; - }); + // The JWT bearer token validation parameters are bound from application + // settings by the composition root (the Api project), so these defaults + // stay free of any app-specific configuration types. } private static void AddHttpLoggingDefaults(this IHostApplicationBuilder builder) diff --git a/src/CleanApiStarter.AspNetCore/Extensions.cs b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultsExtensions.cs similarity index 93% rename from src/CleanApiStarter.AspNetCore/Extensions.cs rename to src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultsExtensions.cs index ca8c5b6..144f138 100644 --- a/src/CleanApiStarter.AspNetCore/Extensions.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/AspNetCoreDefaultsExtensions.cs @@ -1,6 +1,6 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; -public static partial class Extensions +public static partial class AspNetCoreDefaultsExtensions { public static WebApplicationBuilder AddAspNetCoreDefaults(this WebApplicationBuilder builder) { @@ -46,7 +46,7 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) { app.MapGet("/version", () => { - Assembly assembly = Assembly.GetEntryAssembly() ?? typeof(Extensions).Assembly; + Assembly assembly = Assembly.GetEntryAssembly() ?? typeof(AspNetCoreDefaultsExtensions).Assembly; AssemblyInformationalVersionAttribute? informationalVersion = assembly .GetCustomAttribute(); diff --git a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj b/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj similarity index 82% rename from src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj rename to src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj index a27ae2d..5006b81 100644 --- a/src/CleanApiStarter.AspNetCore/CleanApiStarter.AspNetCore.csproj +++ b/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj @@ -1,14 +1,10 @@ - CleanApiStarter.AspNetCore - CleanApiStarter.AspNetCore + CleanApiStarter.AspNetCoreDefaults + CleanApiStarter.AspNetCoreDefaults - - - - diff --git a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs b/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs similarity index 96% rename from src/CleanApiStarter.AspNetCore/GlobalUsings.cs rename to src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs index 1a87110..c9242a1 100644 --- a/src/CleanApiStarter.AspNetCore/GlobalUsings.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs @@ -9,8 +9,6 @@ global using Asp.Versioning.ApiExplorer; global using Asp.Versioning.Builder; -global using CleanApiStarter.Configuration; - global using FluentValidation; global using FluentValidation.Results; diff --git a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs b/src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs similarity index 80% rename from src/CleanApiStarter.AspNetCore/IEndpointGroup.cs rename to src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs index 66a2135..47296b2 100644 --- a/src/CleanApiStarter.AspNetCore/IEndpointGroup.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/IEndpointGroup.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public interface IEndpointGroup { diff --git a/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs b/src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs similarity index 95% rename from src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs rename to src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs index fba74c4..0314cb5 100644 --- a/src/CleanApiStarter.AspNetCore/OpenApiDocumentationExtensions.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/OpenApiDocumentationExtensions.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public static class OpenApiDocumentationExtensions { diff --git a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs b/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs similarity index 93% rename from src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs rename to src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs index 70733a2..21c557c 100644 --- a/src/CleanApiStarter.AspNetCore/OpenTelemetryDefaults.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs @@ -1,6 +1,6 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; -public static partial class Extensions +public static partial class AspNetCoreDefaultsExtensions { private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) { diff --git a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs b/src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs similarity index 98% rename from src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs rename to src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs index ee1e506..7cf981f 100644 --- a/src/CleanApiStarter.AspNetCore/ProblemDetailsExceptionHandler.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/ProblemDetailsExceptionHandler.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class ProblemDetailsExceptionHandler( IProblemDetailsService problemDetailsService, diff --git a/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs b/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs similarity index 83% rename from src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs rename to src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs index 003a9ef..27c9215 100644 --- a/src/CleanApiStarter.AspNetCore/RequestIdMiddleware.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/RequestIdMiddleware.cs @@ -1,8 +1,8 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class RequestIdMiddleware(RequestDelegate next) { - public const string HeaderName = "X-Request-ID"; + private const string HeaderName = "X-Request-ID"; public async Task InvokeAsync(HttpContext context) { diff --git a/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs b/src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs similarity index 95% rename from src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs rename to src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs index 81c7243..0c613a3 100644 --- a/src/CleanApiStarter.AspNetCore/UserIdHttpLoggingInterceptor.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/UserIdHttpLoggingInterceptor.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class UserIdHttpLoggingInterceptor : IHttpLoggingInterceptor { diff --git a/src/CleanApiStarter.AspNetCore/ValidationFilter.cs b/src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs similarity index 97% rename from src/CleanApiStarter.AspNetCore/ValidationFilter.cs rename to src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs index 63b2cc0..4a9d885 100644 --- a/src/CleanApiStarter.AspNetCore/ValidationFilter.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/ValidationFilter.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public sealed class ValidationFilter : IEndpointFilter { diff --git a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs b/src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs similarity index 96% rename from src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs rename to src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs index 87e5166..bf66b9f 100644 --- a/src/CleanApiStarter.AspNetCore/WebApplicationExtensions.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/WebApplicationExtensions.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.AspNetCore; +namespace CleanApiStarter.AspNetCoreDefaults; public static class WebApplicationExtensions { diff --git a/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj b/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj deleted file mode 100644 index 002e9d2..0000000 --- a/src/CleanApiStarter.Configuration/CleanApiStarter.Configuration.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - CleanApiStarter.Configuration - CleanApiStarter.Configuration - - - - - - - - - - diff --git a/src/CleanApiStarter.Configuration/GlobalUsings.cs b/src/CleanApiStarter.Configuration/GlobalUsings.cs deleted file mode 100644 index a2250c9..0000000 --- a/src/CleanApiStarter.Configuration/GlobalUsings.cs +++ /dev/null @@ -1,5 +0,0 @@ -global using System.ComponentModel.DataAnnotations; - -global using Microsoft.Extensions.Configuration; -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Options; diff --git a/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj b/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj deleted file mode 100644 index 115c60c..0000000 --- a/src/CleanApiStarter.Domain/CleanApiStarter.Domain.csproj +++ /dev/null @@ -1,6 +0,0 @@ - - - CleanApiStarter.Domain - CleanApiStarter.Domain - - \ No newline at end of file diff --git a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj b/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj deleted file mode 100644 index 71d039f..0000000 --- a/src/CleanApiStarter.Infrastructure/CleanApiStarter.Infrastructure.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - - CleanApiStarter.Infrastructure - CleanApiStarter.Infrastructure - - - - - - - - - - - - - - - diff --git a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs b/src/CleanApiStarter.Infrastructure/GlobalUsings.cs deleted file mode 100644 index 8801542..0000000 --- a/src/CleanApiStarter.Infrastructure/GlobalUsings.cs +++ /dev/null @@ -1,21 +0,0 @@ -global using System.IdentityModel.Tokens.Jwt; -global using System.Security.Claims; -global using System.Text; - -global using CleanApiStarter.Application.Common.Models; -global using CleanApiStarter.Application.Features.Auth; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.Configuration; -global using CleanApiStarter.Domain.Entities; -global using CleanApiStarter.Infrastructure.Identity; -global using CleanApiStarter.Infrastructure.Persistence; -global using CleanApiStarter.Infrastructure.Repositories; - -global using Google.Apis.Auth; - -global using Microsoft.AspNetCore.Identity; -global using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -global using Microsoft.EntityFrameworkCore; -global using Microsoft.EntityFrameworkCore.Metadata.Builders; -global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.IdentityModel.Tokens; diff --git a/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs b/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs deleted file mode 100644 index c801b9f..0000000 --- a/src/CleanApiStarter.Infrastructure/Identity/ApplicationUser.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace CleanApiStarter.Infrastructure.Identity; - -public sealed class ApplicationUser : IdentityUser; diff --git a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs b/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs deleted file mode 100644 index 02c2906..0000000 --- a/tests/CleanApiStarter.Application.UnitTests/GlobalUsings.cs +++ /dev/null @@ -1,14 +0,0 @@ -global using AutoFixture.Xunit3; - -global using CleanApiStarter.Application.Common.Interfaces; -global using CleanApiStarter.Application.Features.Projects; -global using CleanApiStarter.Domain.Entities; -global using CleanApiStarter.Tests.Common; - -global using Microsoft.Extensions.Logging; - -global using NSubstitute; - -global using Shouldly; - -global using Xunit; diff --git a/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj b/tests/CleanApiStarter.IntegrationTests/CleanApiStarter.IntegrationTests.csproj similarity index 77% rename from tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj rename to tests/CleanApiStarter.IntegrationTests/CleanApiStarter.IntegrationTests.csproj index 275bbf0..2bd0893 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/CleanApiStarter.Api.IntegrationTests.csproj +++ b/tests/CleanApiStarter.IntegrationTests/CleanApiStarter.IntegrationTests.csproj @@ -1,8 +1,8 @@  - CleanApiStarter.Api.IntegrationTests - CleanApiStarter.Api.IntegrationTests + CleanApiStarter.IntegrationTests + CleanApiStarter.IntegrationTests @@ -15,7 +15,7 @@ - + diff --git a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs b/tests/CleanApiStarter.IntegrationTests/Features/Projects/ProjectsTests.cs similarity index 95% rename from tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs rename to tests/CleanApiStarter.IntegrationTests/Features/Projects/ProjectsTests.cs index bc17707..43e8a74 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/Features/Projects/ProjectsTests.cs +++ b/tests/CleanApiStarter.IntegrationTests/Features/Projects/ProjectsTests.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Api.IntegrationTests.Features.Projects; +namespace CleanApiStarter.IntegrationTests.Features.Projects; public sealed class ProjectsTests : IClassFixture> { diff --git a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs b/tests/CleanApiStarter.IntegrationTests/GlobalUsings.cs similarity index 73% rename from tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs rename to tests/CleanApiStarter.IntegrationTests/GlobalUsings.cs index d1837d5..a576aa4 100644 --- a/tests/CleanApiStarter.Api.IntegrationTests/GlobalUsings.cs +++ b/tests/CleanApiStarter.IntegrationTests/GlobalUsings.cs @@ -2,7 +2,7 @@ global using System.Net.Http.Headers; global using System.Net.Http.Json; -global using CleanApiStarter.Tests.Common; +global using CleanApiStarter.TestUtilities.Common; global using Shouldly; diff --git a/tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json b/tests/CleanApiStarter.IntegrationTests/appsettings.Testing.json similarity index 100% rename from tests/CleanApiStarter.Api.IntegrationTests/appsettings.Testing.json rename to tests/CleanApiStarter.IntegrationTests/appsettings.Testing.json diff --git a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj b/tests/CleanApiStarter.TestUtilities/CleanApiStarter.TestUtilities.csproj similarity index 85% rename from tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj rename to tests/CleanApiStarter.TestUtilities/CleanApiStarter.TestUtilities.csproj index 3167dbc..ff57965 100644 --- a/tests/CleanApiStarter.Tests/CleanApiStarter.Tests.csproj +++ b/tests/CleanApiStarter.TestUtilities/CleanApiStarter.TestUtilities.csproj @@ -1,8 +1,8 @@  - CleanApiStarter.Tests - CleanApiStarter.Tests + CleanApiStarter.TestUtilities + CleanApiStarter.TestUtilities diff --git a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs b/tests/CleanApiStarter.TestUtilities/Common/ApiApplicationFactory.cs similarity index 98% rename from tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs rename to tests/CleanApiStarter.TestUtilities/Common/ApiApplicationFactory.cs index 01f01a0..1494598 100644 --- a/tests/CleanApiStarter.Tests/Common/ApiApplicationFactory.cs +++ b/tests/CleanApiStarter.TestUtilities/Common/ApiApplicationFactory.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Tests.Common; +namespace CleanApiStarter.TestUtilities.Common; public sealed class ApiApplicationFactory : WebApplicationFactory, IAsyncLifetime where TProgram : class diff --git a/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs b/tests/CleanApiStarter.TestUtilities/Common/AutoNSubstituteDataAttribute.cs similarity index 84% rename from tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs rename to tests/CleanApiStarter.TestUtilities/Common/AutoNSubstituteDataAttribute.cs index 1ff103c..3befd72 100644 --- a/tests/CleanApiStarter.Tests/Common/AutoNSubstituteDataAttribute.cs +++ b/tests/CleanApiStarter.TestUtilities/Common/AutoNSubstituteDataAttribute.cs @@ -1,4 +1,4 @@ -namespace CleanApiStarter.Tests.Common; +namespace CleanApiStarter.TestUtilities.Common; public sealed class AutoNSubstituteDataAttribute() : AutoDataAttribute(CreateFixture) { diff --git a/tests/CleanApiStarter.Tests/GlobalUsings.cs b/tests/CleanApiStarter.TestUtilities/GlobalUsings.cs similarity index 100% rename from tests/CleanApiStarter.Tests/GlobalUsings.cs rename to tests/CleanApiStarter.TestUtilities/GlobalUsings.cs diff --git a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj b/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj similarity index 65% rename from tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj rename to tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj index 84de8d1..a8c0ef0 100644 --- a/tests/CleanApiStarter.Application.UnitTests/CleanApiStarter.Application.UnitTests.csproj +++ b/tests/CleanApiStarter.UnitTests/CleanApiStarter.UnitTests.csproj @@ -1,8 +1,8 @@ - CleanApiStarter.Application.UnitTests - CleanApiStarter.Application.UnitTests + CleanApiStarter.UnitTests + CleanApiStarter.UnitTests @@ -17,8 +17,12 @@ - - + + + + + + diff --git a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs b/tests/CleanApiStarter.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs similarity index 67% rename from tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs rename to tests/CleanApiStarter.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs index 73c9e68..8209347 100644 --- a/tests/CleanApiStarter.Application.UnitTests/Features/Projects/ProjectServiceTests.cs +++ b/tests/CleanApiStarter.UnitTests/Features/Projects/Tasks/CompleteTaskTests.cs @@ -1,16 +1,15 @@ -namespace CleanApiStarter.Application.UnitTests.Features.Projects; +namespace CleanApiStarter.UnitTests.Features.Projects.Tasks; -public sealed class ProjectServiceTests +public sealed class CompleteTaskTests { [Theory] [AutoNSubstituteData] - public async Task CompleteTaskAsync_TaskIsAlreadyCompleted_ReturnsAlreadyCompleted( + public async Task Handle_TaskIsAlreadyCompleted_ReturnsConflictAndDoesNotSave( Guid projectId, Guid taskId, string userId, [Frozen] IUser currentUser, - [Frozen] IProjectRepository projectRepository, - ProjectService sut) + [Frozen] IProjectRepository projectRepository) { // Arrange CancellationToken cancellationToken = CancellationToken.None; @@ -31,10 +30,10 @@ public async Task CompleteTaskAsync_TaskIsAlreadyCompleted_ReturnsAlreadyComplet .Returns(Task.FromResult(task)); // Act - ProjectTaskMutationResult result = await sut.CompleteTaskAsync(projectId, taskId, cancellationToken); + IResult result = await CompleteTask.Handle(projectId, taskId, projectRepository, currentUser, cancellationToken); // Assert - result.ShouldBe(ProjectTaskMutationResult.AlreadyCompleted); + result.ShouldBeOfType>(); _ = projectRepository.DidNotReceive().SaveChangesAsync(Arg.Any()); } } diff --git a/tests/CleanApiStarter.UnitTests/GlobalUsings.cs b/tests/CleanApiStarter.UnitTests/GlobalUsings.cs new file mode 100644 index 0000000..b0a244a --- /dev/null +++ b/tests/CleanApiStarter.UnitTests/GlobalUsings.cs @@ -0,0 +1,16 @@ +global using AutoFixture.Xunit3; + +global using CleanApiStarter.Api.Common.Interfaces; +global using CleanApiStarter.Api.Domain.Entities; +global using CleanApiStarter.Api.Features.Projects; +global using CleanApiStarter.Api.Features.Projects.Tasks; +global using CleanApiStarter.TestUtilities.Common; + +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Http.HttpResults; + +global using NSubstitute; + +global using Shouldly; + +global using Xunit; From 3ca329e9e6b43df589b56b24a5ecb9e14459db08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:29:34 +0800 Subject: [PATCH 22/33] Bump actions/checkout from 6 to 7 (#9) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/template.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 111c83c..a96342d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 5dfba3b..fe1ae4a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0715734..f0414c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 diff --git a/.github/workflows/template.yml b/.github/workflows/template.yml index 981cc62..bae2c6e 100644 --- a/.github/workflows/template.yml +++ b/.github/workflows/template.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET uses: actions/setup-dotnet@v5 From 04bcc279c512997f719746722b6e1a9c75aed9a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:30:02 +0800 Subject: [PATCH 23/33] Bump the nuget-minor-and-patch group with 2 updates (#10) Bumps Aspire.Hosting.AppHost from 13.4.4 to 13.4.5 Bumps Scalar.AspNetCore from 2.16.3 to 2.16.4 --- updated-dependencies: - dependency-name: Aspire.Hosting.AppHost dependency-version: 13.4.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: Scalar.AspNetCore dependency-version: 2.16.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5d6efd1..e1f0592 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,7 @@ true - 13.4.4 + 13.4.5 10.0.9 10.0.0 10.0.9 @@ -46,7 +46,7 @@ - + From ac1666e2c860238075913c2884450a8ccaf0efbd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:26:56 +0800 Subject: [PATCH 24/33] Bump NsDepCop from 2.7.0 to 3.0.0 (#13) --- updated-dependencies: - dependency-name: NsDepCop dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e1f0592..5db0450 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -37,7 +37,7 @@ - + From be033d73e8f2dd0b19a7187538c858a39098fef6 Mon Sep 17 00:00:00 2001 From: cbjpdev <42979311+cstacklab@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:45:53 +0800 Subject: [PATCH 25/33] fix openapi vulnerability --- Directory.Packages.props | 9 ++-- src/CleanApiStarter.Api/GlobalUsings.cs | 2 - .../CleanApiStarter.AspNetCoreDefaults.csproj | 50 ++++++++++--------- .../GlobalUsings.cs | 2 - .../OpenTelemetryDefaults.cs | 4 +- 5 files changed, 33 insertions(+), 34 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5db0450..f1fbfd8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,7 @@ true - 13.4.5 + 13.4.6 10.0.9 10.0.0 10.0.9 @@ -43,13 +43,13 @@ - - + + - + @@ -57,5 +57,6 @@ + \ No newline at end of file diff --git a/src/CleanApiStarter.Api/GlobalUsings.cs b/src/CleanApiStarter.Api/GlobalUsings.cs index d32fc96..7b71a28 100644 --- a/src/CleanApiStarter.Api/GlobalUsings.cs +++ b/src/CleanApiStarter.Api/GlobalUsings.cs @@ -7,7 +7,6 @@ global using System.Text; global using System.Text.Encodings.Web; -global using CleanApiStarter.Api; global using CleanApiStarter.Api.Common.Interfaces; global using CleanApiStarter.Api.Common.Models; global using CleanApiStarter.Api.Configuration; @@ -36,6 +35,5 @@ global using Microsoft.EntityFrameworkCore.Metadata.Builders; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; -global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options; global using Microsoft.IdentityModel.Tokens; diff --git a/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj b/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj index 5006b81..97d4478 100644 --- a/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj +++ b/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj @@ -1,28 +1,32 @@ - - CleanApiStarter.AspNetCoreDefaults - CleanApiStarter.AspNetCoreDefaults - + + CleanApiStarter.AspNetCoreDefaults + CleanApiStarter.AspNetCoreDefaults + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs b/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs index c9242a1..733ebe8 100644 --- a/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/GlobalUsings.cs @@ -3,7 +3,6 @@ global using System.IO.Compression; global using System.Reflection; global using System.Security.Claims; -global using System.Text; global using Asp.Versioning; global using Asp.Versioning.ApiExplorer; @@ -25,7 +24,6 @@ global using Microsoft.Extensions.Diagnostics.HealthChecks; global using Microsoft.Extensions.Hosting; global using Microsoft.Extensions.Logging; -global using Microsoft.IdentityModel.Tokens; global using Npgsql; diff --git a/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs b/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs index 21c557c..33629c7 100644 --- a/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs +++ b/src/CleanApiStarter.AspNetCoreDefaults/OpenTelemetryDefaults.cs @@ -2,7 +2,7 @@ namespace CleanApiStarter.AspNetCoreDefaults; public static partial class AspNetCoreDefaultsExtensions { - private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder) + private static void ConfigureOpenTelemetry(this IHostApplicationBuilder builder) { builder.Logging.AddOpenTelemetry(logging => { @@ -35,7 +35,5 @@ private static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicat builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter()); builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter()); } - - return builder; } } From a3341d1b0252b673a0a8eda8a787ddecc7d99d8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:30:30 +0800 Subject: [PATCH 26/33] Bump actions/setup-dotnet from 5 to 6 (#17) Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5 to 6. - [Release notes](https://github.com/actions/setup-dotnet/releases) - [Commits](https://github.com/actions/setup-dotnet/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-dotnet dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/template.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a96342d..83cac18 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fe1ae4a..8318718 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0414c9..2597821 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json diff --git a/.github/workflows/template.yml b/.github/workflows/template.yml index bae2c6e..7fc3118 100644 --- a/.github/workflows/template.yml +++ b/.github/workflows/template.yml @@ -16,7 +16,7 @@ jobs: uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: global-json-file: global.json From 1877acebba60d8b0b78db50ded8094c57f7263fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:30:49 +0800 Subject: [PATCH 27/33] Bump github/codeql-action from 4 to 4.37.3 (#23) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8318718..6030b5c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,7 @@ jobs: global-json-file: global.json - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.37.3 with: languages: csharp build-mode: manual @@ -41,4 +41,4 @@ jobs: run: dotnet build CleanApiStarter.slnx --no-restore --configuration Release /nr:false -v:minimal - name: Analyze - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.37.3 From 226229cde22a04a323056e33e7f247e98a90cb4e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:32:09 +0800 Subject: [PATCH 28/33] Bump Asp.Versioning.Http and 21 others (#38) Bumps Asp.Versioning.Http from 10.0.0 to 10.2.3 Bumps Asp.Versioning.Mvc.ApiExplorer from 10.0.0 to 10.2.1 Bumps Asp.Versioning.OpenApi from 10.0.0-rc.1 to 10.2.3 Bumps Aspire.Hosting.AppHost from 13.4.6 to 13.5.3 Bumps dotnet-reportgenerator-globaltool from 5.5.10 to 5.5.11 Bumps Google.Apis.Auth from 1.75.0 to 1.76.0 Bumps MessagePack from 3.1.7 to 3.1.8 Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.9 to 10.0.11 Bumps Microsoft.Extensions.Http.Resilience from 10.7.0 to 10.9.0 Bumps Microsoft.NET.Test.Sdk from 18.6.0 to 18.9.0 Bumps Microsoft.OpenApi from 2.7.5 to 2.12.2 Bumps Npgsql.EntityFrameworkCore.PostgreSQL from 10.0.2 to 10.0.3 Bumps NsDepCop from 3.0.0 to 3.1.0 Bumps OpenTelemetry to 1.18.0 Bumps OpenTelemetry.Exporter.OpenTelemetryProtocol to 1.18.0 Bumps OpenTelemetry.Extensions.Hosting to 1.18.0 Bumps OpenTelemetry.Instrumentation.AspNetCore from 1.16.0 to 1.18.0 Bumps OpenTelemetry.Instrumentation.Http from 1.16.0 to 1.18.0 Bumps OpenTelemetry.Instrumentation.Runtime from 1.15.1 to 1.18.0 Bumps Scalar.AspNetCore from 2.16.4 to 2.17.2 Bumps System.IdentityModel.Tokens.Jwt from 8.19.1 to 8.22.0 Bumps Testcontainers.PostgreSql from 4.13.0 to 4.14.0 --- updated-dependencies: - dependency-name: Asp.Versioning.Http dependency-version: 10.2.3 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Asp.Versioning.Mvc.ApiExplorer dependency-version: 10.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Asp.Versioning.OpenApi dependency-version: 10.2.3 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Aspire.Hosting.AppHost dependency-version: 13.5.3 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: dotnet-reportgenerator-globaltool dependency-version: 5.5.11 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: Google.Apis.Auth dependency-version: 1.76.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: MessagePack dependency-version: 3.1.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer dependency-version: 10.0.11 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: Microsoft.Extensions.Http.Resilience dependency-version: 10.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Microsoft.OpenApi dependency-version: 2.12.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Npgsql.EntityFrameworkCore.PostgreSQL dependency-version: 10.0.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: NsDepCop dependency-version: 3.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Exporter.OpenTelemetryProtocol dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Exporter.OpenTelemetryProtocol dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Extensions.Hosting dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Extensions.Hosting dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Instrumentation.AspNetCore dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Instrumentation.Http dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Instrumentation.Runtime dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Scalar.AspNetCore dependency-version: 2.17.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: System.IdentityModel.Tokens.Jwt dependency-version: 8.22.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Testcontainers.PostgreSql dependency-version: 4.14.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .config/dotnet-tools.json | 2 +- Directory.Packages.props | 42 +++++++++---------- .../CleanApiStarter.AppHost.csproj | 3 ++ .../CleanApiStarter.AspNetCoreDefaults.csproj | 1 + 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 9288b0e..b1388c0 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-reportgenerator-globaltool": { - "version": "5.5.10", + "version": "5.5.11", "commands": [ "reportgenerator" ] diff --git a/Directory.Packages.props b/Directory.Packages.props index f1fbfd8..9fda2fa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,24 +2,24 @@ true - 13.4.6 - 10.0.9 + 13.5.3 + 10.0.11 10.0.0 10.0.9 - 10.7.0 + 10.9.0 - - - + + + - + @@ -34,29 +34,29 @@ - + - - + + - - - - - - - + + + + + + + - - + + - - + + \ No newline at end of file diff --git a/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj b/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj index 8fd8e8e..bcc7264 100644 --- a/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj +++ b/src/CleanApiStarter.AppHost/CleanApiStarter.AppHost.csproj @@ -14,6 +14,9 @@ + + + diff --git a/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj b/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj index 97d4478..6198353 100644 --- a/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj +++ b/src/CleanApiStarter.AspNetCoreDefaults/CleanApiStarter.AspNetCoreDefaults.csproj @@ -27,6 +27,7 @@ + From c73732957179c34ec2f24699d1500e7040a1f244 Mon Sep 17 00:00:00 2001 From: Chathuranga Pathirana <42979311+cstacklab@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:10:56 +0800 Subject: [PATCH 29/33] Migrate to Microsoft.Testing.Platform, bump xunit to 4.0.0, exclude generated code from CodeQL (#40) xunit.v3 4.0.0 pulls a newer Microsoft.Testing.Platform chain that hard-errors on .NET 10 GA when dotnet test runs through the legacy VSTest bridge. Opt in to the native MTP runner via global.json (test.runner) instead of pinning back to VSTest. - global.json: add test.runner = Microsoft.Testing.Platform - Directory.Packages.props: bump xunit.v3 / xunit.v3.extensibility.core / xunit.runner.visualstudio to 4.0.0 - build.yml, template.yml: dotnet test now takes --solution (positional solution paths are rejected under MTP) and drops /nr:false, which silently breaks solution-level test discovery ("Zero tests ran") under the new runner - codeql.yml: point CodeQL at a config file that excludes generated code (obj/**, bin/**) from analysis, since build-mode: manual traces dotnet build and CodeQL was flagging build-generated .cs files under obj/ Verified: clean restore/format/build/test locally and via the packaged template (pack -> install -> instantiate -> restore/build/test), both green, matching the CI invocation exactly. --- .github/codeql/codeql-config.yml | 5 +++++ .github/workflows/build.yml | 2 +- .github/workflows/codeql.yml | 1 + .github/workflows/template.yml | 2 +- Directory.Packages.props | 6 +++--- global.json | 5 ++++- 6 files changed, 15 insertions(+), 6 deletions(-) create mode 100644 .github/codeql/codeql-config.yml diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..948e332 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,5 @@ +name: CodeQL config + +paths-ignore: + - '**/obj/**' + - '**/bin/**' diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 83cac18..cc386da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,4 +30,4 @@ jobs: run: dotnet build CleanApiStarter.slnx --no-restore --configuration Release /nr:false -v:minimal - name: Test - run: dotnet test CleanApiStarter.slnx --no-build --configuration Release /nr:false -v:minimal + run: dotnet test --solution CleanApiStarter.slnx --no-build --configuration Release -v:minimal diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6030b5c..6c74521 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -33,6 +33,7 @@ jobs: languages: csharp build-mode: manual queries: security-extended,security-and-quality + config-file: ./.github/codeql/codeql-config.yml - name: Restore run: dotnet restore CleanApiStarter.slnx diff --git a/.github/workflows/template.yml b/.github/workflows/template.yml index 7fc3118..09d2cf5 100644 --- a/.github/workflows/template.yml +++ b/.github/workflows/template.yml @@ -43,4 +43,4 @@ jobs: - name: Test sample working-directory: artifacts/DemoProduct - run: dotnet test DemoProduct.slnx --no-build --configuration Release /nr:false -v:minimal + run: dotnet test --solution DemoProduct.slnx --no-build --configuration Release -v:minimal diff --git a/Directory.Packages.props b/Directory.Packages.props index 9fda2fa..808d233 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -50,9 +50,9 @@ - - - + + + diff --git a/global.json b/global.json index 8994a62..93c5d4a 100644 --- a/global.json +++ b/global.json @@ -2,5 +2,8 @@ "sdk": { "version": "10.0.203", "rollForward": "latestFeature" + }, + "test": { + "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +} From e99c5d9784df928fefa2c9f07f2fe3a11fa4d734 Mon Sep 17 00:00:00 2001 From: Chathuranga Pathirana <42979311+cstacklab@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:32:45 +0800 Subject: [PATCH 30/33] update scalar and test containers (#41) --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 808d233..def4f77 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -46,10 +46,10 @@ - + - + From d2dc1ced12608e35c416d3827abab7fc7297e57b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:40:47 +1300 Subject: [PATCH 31/33] Bump the nuget-minor-and-patch group with 23 updates (#54) Bumps Aspire.Hosting.AppHost from 13.5.3 to 13.6.3 Bumps coverlet.collector from 10.0.1 to 10.1.0 Bumps FluentValidation from 12.1.1 to 12.2.0 Bumps FluentValidation.DependencyInjectionExtensions from 12.1.1 to 12.2.0 Bumps Google.Apis.Auth from 1.76.0 to 1.77.0 Bumps MessagePack from 3.1.8 to 3.1.12 Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.11 to 10.0.13 Bumps Microsoft.Extensions.Http.Resilience from 10.9.0 to 10.11.0 Bumps Microsoft.NET.Test.Sdk from 18.9.0 to 18.11.0 Bumps Microsoft.OpenApi from 2.12.2 to 2.13.0 Bumps NsDepCop from 3.1.0 to 3.2.0 Bumps OpenTelemetry from 1.18.0 to 1.20.1 Bumps OpenTelemetry.Exporter.OpenTelemetryProtocol from 1.18.0 to 1.20.1 Bumps OpenTelemetry.Extensions.Hosting from 1.18.0 to 1.20.1 Bumps OpenTelemetry.Instrumentation.AspNetCore from 1.18.0 to 1.20.1 Bumps OpenTelemetry.Instrumentation.Http from 1.18.0 to 1.20.1 Bumps OpenTelemetry.Instrumentation.Runtime from 1.18.0 to 1.20.1 Bumps Scalar.AspNetCore from 2.17.3 to 2.18.2 Bumps System.IdentityModel.Tokens.Jwt from 8.22.0 to 8.23.0 Bumps Testcontainers.PostgreSql from 4.15.0 to 4.16.0 Bumps xunit.runner.visualstudio from 4.0.0 to 4.0.1 Bumps xunit.v3 from 4.0.0 to 4.0.2 Bumps xunit.v3.extensibility.core from 4.0.0 to 4.0.2 --- updated-dependencies: - dependency-name: Aspire.Hosting.AppHost dependency-version: 13.6.3 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: coverlet.collector dependency-version: 10.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: FluentValidation dependency-version: 12.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: FluentValidation.DependencyInjectionExtensions dependency-version: 12.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Google.Apis.Auth dependency-version: 1.77.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: MessagePack dependency-version: 3.1.12 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer dependency-version: 10.0.13 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: Microsoft.Extensions.Http.Resilience dependency-version: 10.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.11.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Microsoft.OpenApi dependency-version: 2.13.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: NsDepCop dependency-version: 3.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry dependency-version: 1.20.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Exporter.OpenTelemetryProtocol dependency-version: 1.20.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Extensions.Hosting dependency-version: 1.20.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Instrumentation.AspNetCore dependency-version: 1.20.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Instrumentation.Http dependency-version: 1.20.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: OpenTelemetry.Instrumentation.Runtime dependency-version: 1.20.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Scalar.AspNetCore dependency-version: 2.18.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: System.IdentityModel.Tokens.Jwt dependency-version: 8.23.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: Testcontainers.PostgreSql dependency-version: 4.16.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: nuget-minor-and-patch - dependency-name: xunit.runner.visualstudio dependency-version: 4.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: xunit.v3 dependency-version: 4.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch - dependency-name: xunit.v3.extensibility.core dependency-version: 4.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: nuget-minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Directory.Packages.props | 46 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index def4f77..a550da2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,11 +2,11 @@ true - 13.5.3 - 10.0.11 + 13.6.3 + 10.0.13 10.0.0 10.0.9 - 10.9.0 + 10.11.0 @@ -16,10 +16,10 @@ - - - - + + + + @@ -34,29 +34,29 @@ - + - + - - - - - - - + + + + + + + - - - - - + + + + + - - + + \ No newline at end of file From d3d82369c8e4b5bf1a1586c2146fc1c4922ddc42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:53:24 +1300 Subject: [PATCH 32/33] Bump github/codeql-action from 4.37.3 to 4.39.1 (#53) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.3 to 4.39.1. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.3...v4.39.1) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.39.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6c74521..14a59b7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -28,7 +28,7 @@ jobs: global-json-file: global.json - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.3 + uses: github/codeql-action/init@v4.39.1 with: languages: csharp build-mode: manual @@ -42,4 +42,4 @@ jobs: run: dotnet build CleanApiStarter.slnx --no-restore --configuration Release /nr:false -v:minimal - name: Analyze - uses: github/codeql-action/analyze@v4.37.3 + uses: github/codeql-action/analyze@v4.39.1 From 8c8fc6dc3da0f49d17e6b94253785a7dba04749b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:56:48 +0000 Subject: [PATCH 33/33] Bump Microsoft.OpenApi from 2.13.0 to 3.11.0 --- updated-dependencies: - dependency-name: Microsoft.OpenApi dependency-version: 3.11.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a550da2..4430374 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -57,6 +57,6 @@ - + \ No newline at end of file