Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Important part of every project are **[tests](https://github.com/Gramli/WeatherA
In this solution, each 'code' project has its own unit test project and every **unit test** project copy the same directory structure as 'code' project, which is very helpful for orientation in test project. Infrastructure project has also **integration tests**, because for format conversion is used third party library and we want to know that conversion works always as expected (for example when we update library version).

## Frontend Example
The frontend is a simple Angular 18 project that demonstrates how to upload and download files as blobs or FormData from the C# API. Files are saved to the Downloads folder using the [file-saver](https://www.npmjs.com/package/file-saver) library. For styling, the project utilizes [Bootstrap](https://getbootstrap.com/). Additionally, there are examples of displaying modals with [ng-bootstrap](https://www.npmjs.com/package/@ng-bootstrap/ng-bootstrap) and toasts/notifications with [angular-notifier](https://www.npmjs.com/package/gramli-angular-notifier).
The frontend is a simple Angular project that demonstrates how to upload and download files as blobs or FormData from the C# API. Files are saved to the Downloads folder using the [file-saver](https://www.npmjs.com/package/file-saver) library. For styling, the project utilizes [Bootstrap](https://getbootstrap.com/). It also includes examples of displaying modals with [ng-bootstrap](https://www.npmjs.com/package/@ng-bootstrap/ng-bootstrap) and showing toasts/notifications with [angular-notifier](https://www.npmjs.com/package/gramli-angular-notifier). Additionally, it demonstrates how to use translations in an Angular project with the [@ngx-translate/core](https://ngx-translate.org/) library, including a custom implementation of TranslateLoader to handle multiple translation files.

## Technologies
* [ASP.NET Core 9](https://learn.microsoft.com/en-us/aspnet/core/introduction-to-aspnet-core?view=aspnetcore-9.0)
Expand Down
26 changes: 15 additions & 11 deletions src/File.API/EndpointBuilders/FileEndpointsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public static class FileEndpointsBuilder
public static IEndpointRouteBuilder BuildFileEndpoints(this IEndpointRouteBuilder endpointRouteBuilder)
{
return endpointRouteBuilder
.MapGroup("file")
.MapVersionGroup(1)
.MapGroup("files")
.BuildUploadEndpoints()
.BuildDownloadEndpoints()
.BuildGetEndpoints()
Expand All @@ -29,7 +29,7 @@ public static IEndpointRouteBuilder BuildFileEndpoints(this IEndpointRouteBuilde

private static IEndpointRouteBuilder BuildUploadEndpoints(this IEndpointRouteBuilder endpointRouteBuilder)
{
endpointRouteBuilder.MapPost("upload",
endpointRouteBuilder.MapPost("/upload",
async (IFormFile file, [FromServices] IAddFilesCommandHandler handler, CancellationToken cancellationToken) =>
await handler.SendAsync(new AddFilesCommand([new FormFileProxy(file)]), cancellationToken))
.DisableAntiforgery()
Expand All @@ -41,16 +41,16 @@ await handler.SendAsync(new AddFilesCommand([new FormFileProxy(file)]), cancella

private static IEndpointRouteBuilder BuildDownloadEndpoints(this IEndpointRouteBuilder endpointRouteBuilder)
{
endpointRouteBuilder.MapGet("download",
async ([FromQuery] int id, [FromServices] IDownloadFileQueryHandler handler, CancellationToken cancellationToken) =>
endpointRouteBuilder.MapGet("/{id}/download",
async (int id, [FromServices] IDownloadFileQueryHandler handler, CancellationToken cancellationToken) =>
await handler.GetFileAsync(new DownloadFileQuery(id), cancellationToken))
.DisableAntiforgery()
.Produces<FileContentHttpResult>()
.WithName("DownloadFile")
.WithTags("Get");

endpointRouteBuilder.MapGet("downloadAsJson",
async ([FromQuery] int id, [FromServices] IDownloadFileQueryHandler handler, CancellationToken cancellationToken) =>
endpointRouteBuilder.MapGet("/{id}/download/json",
async (int id, [FromServices] IDownloadFileQueryHandler handler, CancellationToken cancellationToken) =>
await handler.GetJsonFileAsync(new DownloadFileQuery(id), cancellationToken))
.DisableAntiforgery()
.ProducesDataResponse<StringContentFileDto>()
Expand All @@ -62,7 +62,7 @@ await handler.GetJsonFileAsync(new DownloadFileQuery(id), cancellationToken))

private static IEndpointRouteBuilder BuildGetEndpoints(this IEndpointRouteBuilder endpointRouteBuilder)
{
endpointRouteBuilder.MapGet("files-info",
endpointRouteBuilder.MapGet("/",
async ([FromServices] IGetFilesInfoQueryHandler handler, CancellationToken cancellationToken) =>
await handler.SendAsync(EmptyRequest.Instance, cancellationToken))
.ProducesDataResponse<IEnumerable<FileInfoDto>>()
Expand All @@ -73,9 +73,13 @@ await handler.SendAsync(EmptyRequest.Instance, cancellationToken))

private static IEndpointRouteBuilder BuildParseEndpoints(this IEndpointRouteBuilder endpointRouteBuilder)
{
endpointRouteBuilder.MapPost("export",
async ([FromBody]ExportFileQuery parseFileQuery,[FromServices] IExportFileQueryHandler handler, CancellationToken cancellationToken) =>
await handler.GetFileAsync(parseFileQuery, cancellationToken))
endpointRouteBuilder.MapGet("{id}/export",
async (int id, [FromQuery]string extension,[FromServices] IExportFileQueryHandler handler, CancellationToken cancellationToken) =>
await handler.GetFileAsync(new ExportFileQuery
{
Id = id,
Extension = extension
}, cancellationToken))
.DisableAntiforgery()
.ProducesDataResponse<FileContentHttpResult>()
.WithName("Export")
Expand All @@ -85,7 +89,7 @@ await handler.GetFileAsync(parseFileQuery, cancellationToken))

private static IEndpointRouteBuilder BuildExportEndpoints(this IEndpointRouteBuilder endpointRouteBuilder)
{
endpointRouteBuilder.MapPost("convert",
endpointRouteBuilder.MapPost("/convert",
async (IFormFile file, [FromForm]string formatToConvert, [FromServices] IConvertToQueryHandler handler, CancellationToken cancellationToken) =>
await handler.GetFileAsync(new ConvertToQuery(new FormFileProxy(file), formatToConvert), cancellationToken))
.DisableAntiforgery()
Expand Down
2 changes: 1 addition & 1 deletion src/File.API/File.API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="Ardalis.GuardClauses" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="SmallApiToolkit" Version="1.0.0.7" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
</ItemGroup>
Expand Down
3 changes: 2 additions & 1 deletion src/File.Frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@
{
"glob": "**/*",
"input": "public"
}
},
"src/assets"
],
"styles": [
"node_modules/bootstrap/dist/css/bootstrap.min.css",
Expand Down
28 changes: 28 additions & 0 deletions src/File.Frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/File.Frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "^0.2.2",
"@ng-bootstrap/ng-bootstrap": "^18.0.0",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"@popperjs/core": "^2.11.8",
"@types/node": "^22.7.2",
"bootstrap": "^5.3.3",
Expand Down
22 changes: 11 additions & 11 deletions src/File.Frontend/src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div class="container-fluid">
<h1 class="mx-1">File.Frontend</h1>
<div *ngIf="fileService.loading" class="m-2 p-4">
<h4>Processing...</h4>
<h4>{{ 'processing' | translate}}</h4>
<div class="progress">
<div
class="progress-bar progress-bar-striped progress-bar-animated"
Expand All @@ -16,11 +16,11 @@ <h4>Processing...</h4>
<table class="table table-bordered table-hover">
<thead>
<tr>
<td scope="col">Id</td>
<td scope="col">Name</td>
<td scope="col">FileName</td>
<td scope="col">Type</td>
<td scope="col">Action</td>
<td scope="col">{{ 'table.id' | translate }}</td>
<td scope="col">{{ 'table.name' | translate }}</td>
<td scope="col">{{ 'table.fileName' | translate }}</td>
<td scope="col">{{ 'table.type' | translate }}</td>
<td scope="col">{{ 'table.action' | translate }}</td>
</tr>
</thead>
<tbody>
Expand All @@ -34,16 +34,16 @@ <h4>Processing...</h4>
class="btn btn-info btn-sm mx-1"
(click)="onDownloadFile(item.id)"
>
Download
{{ 'download' | translate }}
</button>
<button
class="btn btn-info btn-sm mx-1"
(click)="onDownloadFileAsJson(item.id)"
>
Download as Json
{{ 'downloadAsJson' | translate }}
</button>
<button class="btn btn-info btn-sm mx-1" (click)="export(item.id)">
Export
{{ 'export' | translate }}
</button>
</td>
</tr>
Expand All @@ -67,10 +67,10 @@ <h4>Processing...</h4>
<div>
<button class="btn btn-primary mx-1" (click)="fileUpload.click()">
<fa-icon [icon]="faUpload"></fa-icon>
Upload
{{ 'upload' | translate }}
</button>
<button class="btn btn-primary mx-1" (click)="fileConvert.click()">
<fa-icon [icon]="faFileImport" class="mx-1"></fa-icon>Convert
<fa-icon [icon]="faFileImport" class="mx-1"></fa-icon>{{ 'convert' | translate }}
</button>
</div>
</div>
Expand Down
4 changes: 3 additions & 1 deletion src/File.Frontend/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { saveAs } from 'file-saver';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { FileLoadingService } from './services/file-loading.service';
import { NotificationAdapterService } from './services/notification-adapter.service';
import { TranslateService } from '@ngx-translate/core';

@Component({
selector: 'app-root',
Expand All @@ -26,7 +27,8 @@ export class AppComponent implements OnInit {
constructor(
protected fileService: FileLoadingService,
private ngbModal: NgbModal,
private notifierService: NotificationAdapterService
private notifierService: NotificationAdapterService,
private translateService: TranslateService
) {}

public ngOnInit(): void {
Expand Down
27 changes: 25 additions & 2 deletions src/File.Frontend/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,24 @@ import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { provideHttpClient } from '@angular/common/http';
import { HttpClient, provideHttpClient } from '@angular/common/http';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { FormsModule } from '@angular/forms';
import { SelectExtensionModalComponent } from './components/select-extension-modal.component';
import { NotifierModule } from 'gramli-angular-notifier';
import { MultiTranslateHttpLoader } from './translate/multi-translate-http-loader';
import {
TranslateLoader,
TranslateModule,
} from '@ngx-translate/core';

export function HttpLoaderFactory(http: HttpClient): TranslateLoader {
return new MultiTranslateHttpLoader(http, [
{ prefix: './assets/i18n/', suffix: '.json' },
{ prefix: './assets/i18n/components/', suffix: '.json' },
]);
}

@NgModule({
declarations: [AppComponent, SelectExtensionModalComponent],
Expand All @@ -17,6 +29,15 @@ import { NotifierModule } from 'gramli-angular-notifier';
FontAwesomeModule,
NgbModule,
FormsModule,
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: HttpLoaderFactory,
deps: [HttpClient],
},
lang: 'en',
fallbackLang: 'en'
}),
NotifierModule.withConfig({
position: {
horizontal: {
Expand All @@ -25,7 +46,9 @@ import { NotifierModule } from 'gramli-angular-notifier';
},
}),
],
providers: [provideHttpClient()],
providers: [
provideHttpClient()
],
bootstrap: [AppComponent],
})
export class AppModule {}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div class="fluid-container">
<div class="row m-2">
<label for="extensions">Choose a extension:</label>
<label for="extensions">{{ 'extension' | translate }}</label>
<select
class="form-select my-1"
name="extensions"
Expand All @@ -16,7 +16,7 @@
<div class="col"></div>
<div class="col">
<button class="w-50 btn btn-primary float-end" (click)="submit()">
OK
{{ 'ok' | translate }}
</button>
</div>
</div>
Expand Down
16 changes: 6 additions & 10 deletions src/File.Frontend/src/app/services/file-api.http.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import { Observable } from 'rxjs';
providedIn: 'root',
})
export class FileApiHttpService {
private apiBaseUrl: string = 'https://localhost:7270/file/v1';
private apiBaseUrl: string = 'https://localhost:7270/v1/files';
constructor(private httpClient: HttpClient) {}

public getFiles(): Observable<IDataResponse<IFile[]>> {
return this.httpClient.get<IDataResponse<IFile[]>>(
`${this.apiBaseUrl}/files-info`
`${this.apiBaseUrl}/`
);
}

Expand All @@ -26,7 +26,7 @@ export class FileApiHttpService {
}

public downloadFile(id: number): Observable<Blob> {
return this.httpClient.get(`${this.apiBaseUrl}/download?id=${id}`, {
return this.httpClient.get(`${this.apiBaseUrl}/${id}/download/`, {
responseType: 'blob',
});
}
Expand All @@ -35,17 +35,13 @@ export class FileApiHttpService {
id: number
): Observable<IDataResponse<IBase64File>> {
return this.httpClient.get<IDataResponse<IBase64File>>(
`${this.apiBaseUrl}/downloadAsJson?id=${id}`
`${this.apiBaseUrl}/${id}/download/json`
);
}

public exportFile(id: number, extension: string): Observable<Blob> {
return this.httpClient.post(
`${this.apiBaseUrl}/export`,
{
id,
extension,
},
return this.httpClient.get(
`${this.apiBaseUrl}/${id}/export?extension=${extension}`,
{ responseType: 'blob' }
);
}
Expand Down
21 changes: 21 additions & 0 deletions src/File.Frontend/src/app/translate/multi-translate-http-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Observable, forkJoin } from 'rxjs';
import { map } from 'rxjs/operators';
import { HttpClient } from '@angular/common/http';
import { TranslateLoader } from '@ngx-translate/core';

export class MultiTranslateHttpLoader implements TranslateLoader {
constructor(
private http: HttpClient,
public resources: { prefix: string; suffix: string }[]
) {}

public getTranslation(lang: string): Observable<any> {
return forkJoin(
this.resources.map(config =>
this.http.get(`${config.prefix}${lang}${config.suffix}`)
)
).pipe(
map(response => response.reduce((acc, obj) => ({ ...acc, ...obj }), {}))
);
}
}
4 changes: 4 additions & 0 deletions src/File.Frontend/src/assets/i18n/components/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extension": "Choose a extension:",
"ok": "OK"
}
16 changes: 16 additions & 0 deletions src/File.Frontend/src/assets/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"ok": "OK",
"processing": "Processing...",
"upload": "Upload",
"downloadAsJson": "Download as Json",
"download": "Download",
"export": "Export",
"convert": "Convert",
"table": {
"id": "Id",
"name": "Name",
"fileName": "FileName",
"type": "Type",
"action": "Action"
}
}
Loading