Compare commits

...

4 Commits

Author SHA1 Message Date
Jeas0001
184fbb3728 Merge branch 'master' of git.reim.ar:ReiMerc/easyeat 2025-04-28 08:35:15 +02:00
Jeas0001
4d2948f3f3 Merge branch 'master' of git.reim.ar:ReiMerc/easyeat 2025-04-24 12:29:22 +02:00
Jeas0001
73bfd8d01c UserController done 2025-04-24 12:27:58 +02:00
Jeas0001
3423c9df34 Updated gitignore 2025-04-24 12:27:41 +02:00
18 changed files with 797 additions and 64 deletions

1
.gitignore vendored
View File

@ -5,3 +5,4 @@
/backend/API/obj
/backend/API/appsettings.Development.json
/backend/API/appsettings.json
*bin

View File

@ -7,6 +7,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MySql.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>

View File

@ -0,0 +1,236 @@
using API.DBAccess;
using API.Models.UserModels;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Asn1.Ocsp;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace API.BusinessLogic
{
public class UserLogic
{
private readonly DbAccess _dbAccess;
private readonly IConfiguration _configuration;
public UserLogic(IConfiguration configuration, DbAccess dbAccess)
{
_dbAccess = dbAccess;
_configuration = configuration;
}
public async Task<IActionResult> GetUser(int userId)
{
User user = await _dbAccess.ReadUser(userId);
if (user == null || user.Id == 0) { return new ConflictObjectResult(new { message = "Could not find user" }); }
return new OkObjectResult(new { user.Id, user.UserName, user.Email });
}
public async Task<IActionResult> RegisterUser(CreateUserDTO userDTO)
{
if (!EmailCheck(userDTO.Email))
{
return new ConflictObjectResult(new { message = "Invalid email address" });
}
if (!PasswordSecurity(userDTO.Password))
{
return new ConflictObjectResult(new { message = "Password is not up to the security standard" });
}
var users = await _dbAccess.ReadAllUsers();
foreach (var item in users)
{
if (item.UserName == userDTO.UserName)
{
return new ConflictObjectResult(new { message = "Username is already in use." });
}
if (item.Email == userDTO.Email)
{
return new ConflictObjectResult(new { message = "Email is already in use." });
}
}
string salt = Guid.NewGuid().ToString();
string hashedPassword = ComputeHash(userDTO.Password, SHA256.Create(), salt);
User user = new User
{
UserName = userDTO.UserName,
Email = userDTO.Email,
Password = hashedPassword,
Salt = salt,
};
return await _dbAccess.CreateUser(user);
}
public async Task<IActionResult> Login(LoginDTO loginDTO)
{
var user = await _dbAccess.ReadUserForLogin(loginDTO.EmailUsr);
if (user == null || user.Id == 0) { return new ConflictObjectResult(new { message = "Could not find user" }); }
string hashedPassword = ComputeHash(loginDTO.Password, SHA256.Create(), user.Salt);
if (user.Password == hashedPassword)
{
var token = GenerateJwtToken(user);
user = await UpdateRefreshToken(user);
return new OkObjectResult(new { token, user.UserName, user.Id, refreshToken = user.RefreshToken });
}
return new ConflictObjectResult(new { message = "Invalid password" });
}
public async Task<IActionResult> EditProfile(UpdateUserDTO userDTO, int userId)
{
var profile = await _dbAccess.ReadUser(userId);
var users = await _dbAccess.ReadAllUsers();
if (profile == null) { return new ConflictObjectResult(new { message = "User does not exist" }); }
if (!EmailCheck(userDTO.Email))
{
return new ConflictObjectResult(new { message = "Invalid email address" });
}
foreach (var item in users)
{
if (item.UserName == userDTO.UserName)
{
return new ConflictObjectResult(new { message = "Username is already in use." });
}
if (item.Email == userDTO.Email)
{
return new ConflictObjectResult(new { message = "Email is already in use." });
}
}
if (userDTO.Email == "" || userDTO.Email == null)
{
return new ConflictObjectResult(new { message = "Please enter an email" });
}
if (userDTO.UserName == "" || userDTO.UserName == null)
{
return new ConflictObjectResult(new { message = "Please enter an userName" });
}
profile.Email = userDTO.Email;
profile.UserName = userDTO.UserName;
return await _dbAccess.UpdateUser(profile);
}
public async Task<IActionResult> ChangePassword(ChangePasswordDTO passwordDTO, int userId)
{
var user = await _dbAccess.ReadUser(userId);
if (user == null) { return new ConflictObjectResult(new { message = "User does not exist" }); }
string hashedPassword = ComputeHash(passwordDTO.OldPassword, SHA256.Create(), user.Salt);
if (user.Password != hashedPassword)
{
return new ConflictObjectResult(new { message = "Old password is incorrect" });
}
if (!PasswordSecurity(passwordDTO.NewPassword))
{
return new ConflictObjectResult(new { message = "New password is not up to the security standard" });
}
string hashedNewPassword = ComputeHash(passwordDTO.NewPassword, SHA256.Create(), user.Salt);
user.Password = hashedNewPassword;
return await _dbAccess.UpdatePassword(user);
}
public async Task<IActionResult> DeleteUser(int userId)
{
var user = await _dbAccess.ReadUserForDelete(userId);
if (user != null) { return await _dbAccess.DeleteUser(user); }
return new ConflictObjectResult(new { message = "Invalid user" });
}
public async Task<IActionResult> RefreshToken(string refreshToken)
{
User user = await _dbAccess.ReadUserByRefreshToken(refreshToken);
if (user == null) { return new ConflictObjectResult(new { message = "Could not match refreshtoken" }); }
user = await UpdateRefreshToken(user);
string jwtToken = GenerateJwtToken(user);
return new OkObjectResult(new { token = jwtToken, refreshToken = user.RefreshToken });
}
private bool PasswordSecurity(string password)
{
var hasMinimum8Chars = new Regex(@".{8,}");
return hasMinimum8Chars.IsMatch(password);
}
private bool EmailCheck(string email)
{
return new Regex(@".+@.+\..+").IsMatch(email);
}
private static string ComputeHash(string input, HashAlgorithm algorithm, string salt)
{
Byte[] inputBytes = Encoding.UTF8.GetBytes(input);
Byte[] saltBytes = Encoding.UTF8.GetBytes(salt);
// Combine salt and input bytes
Byte[] saltedInput = new Byte[saltBytes.Length + inputBytes.Length];
saltBytes.CopyTo(saltedInput, 0);
inputBytes.CopyTo(saltedInput, saltBytes.Length);
Byte[] hashedBytes = algorithm.ComputeHash(saltedInput);
return BitConverter.ToString(hashedBytes);
}
private string GenerateJwtToken(User user)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.Name, user.UserName)
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes
(_configuration["JwtSettings:Key"]));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
_configuration["JwtSettings:Issuer"],
_configuration["JwtSettings:Audience"],
claims,
expires: DateTime.Now.AddHours(1),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
private async Task<User> UpdateRefreshToken(User user)
{
user.RefreshToken = Guid.NewGuid().ToString();
user.RefreshTokenExpireAt = DateTime.Now.AddDays(30);
await _dbAccess.UpdateUser(user);
return user;
}
}
}

View File

@ -0,0 +1,78 @@
using API.BusinessLogic;
using API.Models.UserModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace API.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class UserController : Controller
{
private readonly UserLogic _userLogic;
public UserController(UserLogic userLogic)
{
_userLogic = userLogic;
}
[Authorize]
[HttpGet("get")]
public async Task<IActionResult> ReadUser()
{
var claims = HttpContext.User.Claims;
string userIdString = claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier).Value;
int userId = Convert.ToInt32(userIdString);
return await _userLogic.GetUser(userId);
}
[HttpPost("login")]
public async Task<IActionResult> Login([FromBody] LoginDTO loginDTO)
{
return await _userLogic.Login(loginDTO);
}
[HttpPost("create")]
public async Task<IActionResult> CreateUser([FromBody] CreateUserDTO userDTO)
{
return await _userLogic.RegisterUser(userDTO);
}
[Authorize]
[HttpPut("change-password")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordDTO passwordDTO)
{
var claims = HttpContext.User.Claims;
string userIdString = claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier).Value;
int userId = Convert.ToInt32(userIdString);
return await _userLogic.ChangePassword(passwordDTO, userId);
}
[Authorize]
[HttpPut("update")]
public async Task<IActionResult> UpdateUser([FromBody] UpdateUserDTO userDTO)
{
var claims = HttpContext.User.Claims;
string userIdString = claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier).Value;
int userId = Convert.ToInt32(userIdString);
return await _userLogic.EditProfile(userDTO, userId);
}
[Authorize]
[HttpDelete("delete")]
public async Task<IActionResult> DeleteUser()
{
var claims = HttpContext.User.Claims;
string userIdString = claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier).Value;
int userId = Convert.ToInt32(userIdString);
return await _userLogic.DeleteUser(userId);
}
[HttpPost("refreshtoken/{refreshToken}")]
public async Task<IActionResult> RefreashToken(string refreshToken)
{
return await _userLogic.RefreshToken(refreshToken);
}
}
}

View File

@ -1,33 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
}

View File

@ -0,0 +1,12 @@
using API.Models.UserModels;
using Microsoft.EntityFrameworkCore;
namespace API.DBAccess
{
public class DBContext : DbContext
{
public DbSet<User> Users { get; set; }
public DBContext(DbContextOptions<DBContext> options) : base(options) { }
}
}

View File

@ -0,0 +1,93 @@
using API.Models.UserModels;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace API.DBAccess
{
public class DbAccess
{
private readonly DBContext _context;
public DbAccess(DBContext context)
{
_context = context;
}
public async Task<User> ReadUser(int userId)
{
return await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
}
public async Task<List<User>> ReadAllUsers()
{
return await _context.Users.ToListAsync();
}
public async Task<User> ReadUserByRefreshToken(string refreshToken)
{
return await _context.Users.FirstOrDefaultAsync(u => u.RefreshToken == refreshToken);
}
public async Task<User> ReadUserForDelete(int userId)
{
return await _context.Users.FirstOrDefaultAsync(u => u.Id == userId);
}
public async Task<User> ReadUserForLogin(string emailOrUsername)
{
if (emailOrUsername.Contains("@"))
{
return await _context.Users.FirstOrDefaultAsync(u => u.Email == emailOrUsername);
}
else
{
return await _context.Users.FirstOrDefaultAsync(u => u.UserName == emailOrUsername);
}
}
public async Task<IActionResult> CreateUser(User user)
{
_context.Users.Add(user);
bool saved = await _context.SaveChangesAsync() == 1;
if (saved) { return new OkObjectResult(true); }
return new ConflictObjectResult(new { message = "Could not save to database" });
}
public async Task<IActionResult> UpdateUser(User user)
{
_context.Entry(user).State = EntityState.Modified;
bool saved = await _context.SaveChangesAsync() == 1;
if (saved) { return new OkObjectResult(user); }
return new ConflictObjectResult(new { message = "Could not save to database" });
}
public async Task<IActionResult> UpdatePassword(User user)
{
_context.Entry(user).State = EntityState.Modified;
bool saved = await _context.SaveChangesAsync() == 1;
if (saved) { return new OkObjectResult(user); }
return new ConflictObjectResult(new { message = "Could not save to database" });
}
public async Task<IActionResult> DeleteUser(User user)
{
_context.Users.Remove(user);
bool saved = await _context.SaveChangesAsync() >= 0;
if (saved) { return new OkObjectResult(saved); }
return new ConflictObjectResult(new { message = "Could not save to database" });
}
}
}

View File

@ -0,0 +1,59 @@
// <auto-generated />
using System;
using API.DBAccess;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace API.Migrations
{
[DbContext(typeof(DBContext))]
[Migration("20250423074254_init")]
partial class init
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("API.Models.UserModels.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("RefreshToken")
.HasColumnType("longtext");
b.Property<DateTime>("RefreshTokenExpireAt")
.HasColumnType("datetime(6)");
b.Property<string>("Salt")
.HasColumnType("longtext");
b.Property<string>("UserName")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,45 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using MySql.EntityFrameworkCore.Metadata;
#nullable disable
namespace API.Migrations
{
/// <inheritdoc />
public partial class init : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
UserName = table.Column<string>(type: "longtext", nullable: false),
Password = table.Column<string>(type: "longtext", nullable: false),
Email = table.Column<string>(type: "longtext", nullable: false),
Salt = table.Column<string>(type: "longtext", nullable: true),
RefreshToken = table.Column<string>(type: "longtext", nullable: true),
RefreshTokenExpireAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@ -0,0 +1,56 @@
// <auto-generated />
using System;
using API.DBAccess;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace API.Migrations
{
[DbContext(typeof(DBContext))]
partial class DBContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("API.Models.UserModels.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("RefreshToken")
.HasColumnType("longtext");
b.Property<DateTime>("RefreshTokenExpireAt")
.HasColumnType("datetime(6)");
b.Property<string>("Salt")
.HasColumnType("longtext");
b.Property<string>("UserName")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,9 @@
namespace API.Models.UserModels
{
public class ChangePasswordDTO
{
public string OldPassword { get; set; }
public string NewPassword { get; set; }
}
}

View File

@ -0,0 +1,11 @@
namespace API.Models.UserModels
{
public class CreateUserDTO
{
public string UserName { get; set; }
public string Password { get; set; }
public string Email { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace API.Models.UserModels
{
public class LoginDTO
{
public string EmailUsr { get; set; }
public string Password { get; set; }
}
}

View File

@ -0,0 +1,9 @@
namespace API.Models.UserModels
{
public class UpdateUserDTO
{
public string UserName { get; set; }
public string Email { get; set; }
}
}

View File

@ -0,0 +1,19 @@
namespace API.Models.UserModels
{
public class User
{
public int Id { get; set; }
public string UserName { get; set; }
public string Password { get; set; }
public string Email { get; set; }
public string? Salt { get; set; }
public string? RefreshToken { get; set; }
public DateTime RefreshTokenExpireAt { get; set; }
}
}

View File

@ -1,25 +1,34 @@
var builder = WebApplication.CreateBuilder(args);
using API;
using Microsoft.AspNetCore;
using API.DBAccess;
using Microsoft.EntityFrameworkCore;
// Add services to the container.
builder.Services.AddControllers();
// 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())
internal class Program
{
app.UseSwagger();
app.UseSwaggerUI();
}
private static void Main(string[] args)
{
var app = CreateWebHostBuilder(args).Build();
app.UseHttpsRedirection();
RunMigrations(app);
app.UseAuthorization();
app.Run();
}
app.MapControllers();
// Calls the startup class and creates the webinterface
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseUrls("http://0.0.0.0:5000")
.UseStartup<Startup>();
app.Run();
public static async void RunMigrations(IWebHost app)
{
await using var scope = app.Services.CreateAsyncScope();
await using var db = scope.ServiceProvider.GetService<DBContext>();
if (db != null)
{
await db.Database.MigrateAsync();
}
}
}

122
backend/API/Startup.cs Normal file
View File

@ -0,0 +1,122 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using System.Text;
using API.DBAccess;
using API.BusinessLogic;
namespace API
{
public class Startup
{
public IConfiguration _configuration { get; }
public Startup(IConfiguration configuration)
{
_configuration = configuration;
}
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
// Sets the connectionstring to the database so dbcontext can find it
services.AddDbContext<DBContext>(options =>
options.UseMySQL(_configuration.GetConnectionString("Database")));
services.AddScoped<DbAccess>();
services.AddScoped<UserLogic>();
services.AddControllers();
services.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(x =>
{
x.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = _configuration["JwtSettings:Issuer"],
ValidAudience = _configuration["JwtSettings:Audience"],
IssuerSigningKey = new SymmetricSecurityKey
(
Encoding.UTF8.GetBytes(_configuration["JwtSettings:Key"])
),
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true
};
});
services.AddCors(options =>
{
options.AddPolicy("AllowAll", builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod().AllowAnyHeader();
});
});
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
// Configure Swagger to use Bearer token authentication
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "JWT Authorization header using the Bearer scheme",
Type = SecuritySchemeType.Http,
Scheme = "bearer"
});
c.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
new string[] { }
}
});
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
});
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseCors("AllowAll");
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
}

View File

@ -1,13 +0,0 @@
namespace API
{
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
public string? Summary { get; set; }
}
}